Merge pull request #401 from OxygenCobalt/3.0.4-cherrypick

Version 3.0.4
This commit is contained in:
Alexander Capehart 2023-03-25 15:43:35 +00:00 committed by GitHub
commit 5b4697410b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
248 changed files with 3715 additions and 2071 deletions

View file

@ -59,19 +59,9 @@ body:
- type: textarea - type: textarea
id: logs id: logs
attributes: attributes:
label: Relevant log output label: Bug report
description: | description: |
If possible, provide a stack trace or a Logcat. This can help identify the issue. If possible, provide a "bug report" ZIP file to make it easier to identify the issue. Go to [here](https://developer.android.com/studio/debug/bug-report) for guidance on how to take one.
To take a logcat, you must do the following:
1. Use a desktop/laptop to download the android platform tools from [here](https://developer.android.com/studio/releases/platform-tools).
2. Extract the downloaded file to a folder.
3. Enable USB debugging on your phone [Instructions](https://developer.android.com/studio/command-line/adb#Enabling), and then connect your
phone to a laptop. You will get a prompt to "Allow USB debugging" when you run the logcat command. Accept this.
4. Open up a terminal/command prompt in that folder and run:
- `./adb -d logcat | grep -i "[DWE] Auxio"` in the case of a bug (may require some changes on windows)
- `./adb -d logcat AndroidRuntime:E *:S` in the case of a crash
5. Copy and paste the output to this area of the issue.
render: shell
validations: validations:
required: true required: true
- type: checkboxes - type: checkboxes

View file

@ -1,5 +1,29 @@
# Changelog # Changelog
## 3.0.4
#### What's New
- Added support for `COMPILATION` and `ITUNESCOMPILATION` flags.
#### What's Improved
- Accept `REPLAYGAIN_*` adjustment information on OPUS files alongside
`R128_*` adjustments
- List updates are now consistent across the app
- Fixed jarring header update in detail view
- Search view now trims search queries
- Audio effect (equalizer) session is now broadcast when playing/pausing
rather than on start/stop
- Searching now ignores punctuation
- Numeric names are now logically sorted (i.e 7 before 15)
#### What's Fixed
- Fixed MP4-AAC files not playing due to an accidental audio extractor
deletion
- Fix "format" not appearing in song properties view
#### What's Changed
- "Ignore articles when sorting" is now "Intelligent sorting"
## 3.0.3 ## 3.0.3
#### What's New #### What's New
@ -24,7 +48,6 @@ while selecting it.
#### Dev/Meta #### Dev/Meta
- Started using dependency injection - Started using dependency injection
- Started code obsfucation
- Only bundle audio-related extractors with ExoPlayer - Only bundle audio-related extractors with ExoPlayer
- Switched to Room for database management - Switched to Room for database management
- Updated to MDC 1.8.0 alpha-01 - Updated to MDC 1.8.0 alpha-01

@ -1 +1 @@
Subproject commit 268d683bab060fff43e75732248416d9bf476ef3 Subproject commit fef2bb3af622f235d98549ffe2efd8f7f7d2aa41

View file

@ -2,16 +2,16 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.3"> <a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.4">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.3&color=0D5AF5"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.4&color=64B5F6&style=flat">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg"> <img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
</a> </a>
<a href="https://www.gnu.org/licenses/gpl-3.0"> <a href="https://www.gnu.org/licenses/gpl-3.0">
<img src="https://img.shields.io/badge/license-GPL%20v3-blue.svg"> <img src="https://img.shields.io/badge/license-GPL%20v3-2B6DBE.svg?style=flat">
</a> </a>
<img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-21%2B-32B5ED"> <img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-21%2B-1450A8?style=flat">
</p> </p>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a></h4> <h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a></h4>
<p align="center"> <p align="center">
@ -70,9 +70,10 @@ precise/original dates, sort tags, and more
Auxio relies on a custom version of ExoPlayer that enables some extra features. This adds some caveats to Auxio relies on a custom version of ExoPlayer that enables some extra features. This adds some caveats to
the build process: the build process:
1. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly 1. `cmake` and `ninja-build` must be installed before building the project.
download in the external code. 2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
2. You are **unable** to build this project on windows, as the custom ExoPlayer build runs shell scripts that download the external code.
3. You are **unable** to build this project on windows, as the custom ExoPlayer build runs shell scripts that
will only work on unix-based systems. will only work on unix-based systems.
## Contributing ## Contributing

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) $today.year Auxio Project * Copyright (c) $today.year Auxio Project
* $FILE is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -20,8 +20,8 @@ android {
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "3.0.3" versionName "3.0.4"
versionCode 27 versionCode 28
minSdk 21 minSdk 21
targetSdk 33 targetSdk 33
@ -57,6 +57,14 @@ android {
} }
} }
packagingOptions {
exclude "DebugProbesKt.bin"
exclude "kotlin-tooling-metadata.json"
exclude "**/kotlin/**"
exclude "**/okhttp3/**"
exclude "META-INF/*.version"
}
buildFeatures { buildFeatures {
viewBinding true viewBinding true
} }
@ -67,7 +75,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.6.4"
// --- SUPPORT --- // --- SUPPORT ---
@ -79,13 +88,13 @@ dependencies {
implementation "androidx.fragment:fragment-ktx:1.5.5" implementation "androidx.fragment:fragment-ktx:1.5.5"
// UI // UI
implementation "androidx.recyclerview:recyclerview:1.2.1" implementation "androidx.recyclerview:recyclerview:1.3.0"
implementation "androidx.constraintlayout:constraintlayout:2.1.4" implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01" implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
implementation 'androidx.core:core-ktx:+' implementation 'androidx.core:core-ktx:1.9.0'
// Lifecycle // Lifecycle
def lifecycle_version = "2.5.1" def lifecycle_version = "2.6.0"
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@ -128,6 +137,7 @@ dependencies {
kapt "com.google.dagger:dagger-compiler:$dagger_version" kapt "com.google.dagger:dagger-compiler:$dagger_version"
implementation "com.google.dagger:hilt-android:$hilt_version" implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version" kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
// Testing // Testing
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation "junit:junit:4.13.2" testImplementation "junit:junit:4.13.2"

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* StubTest.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Auxio.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -30,6 +31,7 @@ import org.oxycblt.auxio.ui.UISettings
/** /**
* A simple, rational music player for android. * A simple, rational music player for android.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@HiltAndroidApp @HiltAndroidApp

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* IntegerTable.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -20,6 +21,7 @@ package org.oxycblt.auxio
/** /**
* A table containing all of the magic integer codes that the codebase has currently reserved. May * A table containing all of the magic integer codes that the codebase has currently reserved. May
* be non-contiguous. * be non-contiguous.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
object IntegerTable { object IntegerTable {
@ -35,18 +37,12 @@ object IntegerTable {
const val VIEW_TYPE_BASIC_HEADER = 0xA004 const val VIEW_TYPE_BASIC_HEADER = 0xA004
/** SortHeaderViewHolder */ /** SortHeaderViewHolder */
const val VIEW_TYPE_SORT_HEADER = 0xA005 const val VIEW_TYPE_SORT_HEADER = 0xA005
/** AlbumDetailViewHolder */
const val VIEW_TYPE_ALBUM_DETAIL = 0xA006
/** AlbumSongViewHolder */ /** AlbumSongViewHolder */
const val VIEW_TYPE_ALBUM_SONG = 0xA007 const val VIEW_TYPE_ALBUM_SONG = 0xA007
/** ArtistDetailViewHolder */
const val VIEW_TYPE_ARTIST_DETAIL = 0xA008
/** ArtistAlbumViewHolder */ /** ArtistAlbumViewHolder */
const val VIEW_TYPE_ARTIST_ALBUM = 0xA009 const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
/** ArtistSongViewHolder */ /** ArtistSongViewHolder */
const val VIEW_TYPE_ARTIST_SONG = 0xA00A const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** GenreDetailViewHolder */
const val VIEW_TYPE_GENRE_DETAIL = 0xA00B
/** DiscHeaderViewHolder */ /** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00C const val VIEW_TYPE_DISC_HEADER = 0xA00C
/** "Music playback" notification code */ /** "Music playback" notification code */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* MainActivity.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -40,17 +41,13 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* Auxio's single [AppCompatActivity]. * Auxio's single [AppCompatActivity].
* *
* TODO: Add error screens
*
* TODO: Custom language support
*
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
*
* TODO: Migrate to material animation system
*
* TODO: Unit testing
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Add error screens
* TODO: Custom language support
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
* TODO: Migrate to material animation system
* TODO: Unit testing
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -112,9 +109,10 @@ class MainActivity : AppCompatActivity() {
/** /**
* Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action] that can be used * Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action] that can be used
* in the playback system. * in the playback system.
*
* @param intent The (new) [Intent] given to this [MainActivity], or null if there is no intent. * @param intent The (new) [Intent] given to this [MainActivity], or null if there is no intent.
* @return true If the analogous [InternalPlayer.Action] to the given [Intent] was started, * @return true If the analogous [InternalPlayer.Action] to the given [Intent] was started,
* false otherwise. * false otherwise.
*/ */
private fun startIntentAction(intent: Intent?): Boolean { private fun startIntentAction(intent: Intent?): Boolean {
if (intent == null) { if (intent == null) {

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* MainFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -51,6 +52,7 @@ import org.oxycblt.auxio.util.*
/** /**
* A wrapper around the home fragment that shows the playback fragment and controls the more * A wrapper around the home fragment that shows the playback fragment and controls the more
* high-level navigation features. * high-level navigation features.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
@ -127,12 +129,12 @@ class MainFragment :
} }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(navModel.mainNavigationAction, ::handleMainNavigation) collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
collect(navModel.exploreNavigationItem, ::handleExploreNavigation) collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem, ::handleArtistNavigationPicker) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong, ::handlePlaybackArtistPicker) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong, ::handlePlaybackGenrePicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
} }
override fun onStart() { override fun onStart() {
@ -268,10 +270,11 @@ class MainFragment :
when (action) { when (action) {
is MainNavigationAction.Expand -> tryExpandSheets() is MainNavigationAction.Expand -> tryExpandSheets()
is MainNavigationAction.Collapse -> tryCollapseSheets() is MainNavigationAction.Collapse -> tryCollapseSheets()
is MainNavigationAction.Directions -> findNavController().navigate(action.directions) is MainNavigationAction.Directions ->
findNavController().navigateSafe(action.directions)
} }
navModel.finishMainNavigation() navModel.mainNavigationAction.consume()
} }
private fun handleExploreNavigation(item: Music?) { private fun handleExploreNavigation(item: Music?) {
@ -285,7 +288,7 @@ class MainFragment :
navModel.mainNavigateTo( navModel.mainNavigateTo(
MainNavigationAction.Directions( MainNavigationAction.Directions(
MainFragmentDirections.actionPickNavigationArtist(item.uid))) MainFragmentDirections.actionPickNavigationArtist(item.uid)))
navModel.finishExploreNavigation() navModel.exploreArtistNavigationItem.consume()
} }
} }
@ -302,7 +305,7 @@ class MainFragment :
navModel.mainNavigateTo( navModel.mainNavigateTo(
MainNavigationAction.Directions( MainNavigationAction.Directions(
MainFragmentDirections.actionPickPlaybackArtist(song.uid))) MainFragmentDirections.actionPickPlaybackArtist(song.uid)))
playbackModel.finishPlaybackArtistPicker() playbackModel.artistPickerSong.consume()
} }
} }
@ -311,7 +314,7 @@ class MainFragment :
navModel.mainNavigateTo( navModel.mainNavigateTo(
MainNavigationAction.Directions( MainNavigationAction.Directions(
MainFragmentDirections.actionPickPlaybackGenre(song.uid))) MainFragmentDirections.actionPickPlaybackGenre(song.uid)))
playbackModel.finishPlaybackGenrePicker() playbackModel.genrePickerSong.consume()
} }
} }

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AlbumDetailFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -24,16 +25,18 @@ import android.view.View
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -47,11 +50,14 @@ import org.oxycblt.auxio.util.*
/** /**
* A [ListFragment] that shows information about an [Album]. * A [ListFragment] that shows information about an [Album].
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class AlbumDetailFragment : class AlbumDetailFragment :
ListFragment<Song, FragmentDetailBinding>(), AlbumDetailAdapter.Listener { ListFragment<Song, FragmentDetailBinding>(),
AlbumDetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Song> {
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
@ -59,7 +65,8 @@ class AlbumDetailFragment :
// Information about what album to display is initially within the navigation arguments // Information about what album to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an album. // as a UID, as that is the only safe way to parcel an album.
private val args: AlbumDetailFragmentArgs by navArgs() private val args: AlbumDetailFragmentArgs by navArgs()
private val detailAdapter = AlbumDetailAdapter(this) private val albumHeaderAdapter = AlbumDetailHeaderAdapter(this)
private val albumListAdapter = AlbumDetailListAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -86,7 +93,7 @@ class AlbumDetailFragment :
setOnMenuItemClickListener(this@AlbumDetailFragment) setOnMenuItemClickListener(this@AlbumDetailFragment)
} }
binding.detailRecycler.adapter = detailAdapter binding.detailRecycler.adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
// -- VIEWMODEL SETUP --- // -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
@ -95,7 +102,7 @@ class AlbumDetailFragment :
collectImmediately(detailModel.albumList, ::updateList) collectImmediately(detailModel.albumList, ::updateList)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
} }
@ -103,6 +110,9 @@ class AlbumDetailFragment :
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.albumInstructions.consume()
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
@ -181,14 +191,15 @@ class AlbumDetailFragment :
return return
} }
requireBinding().detailToolbar.title = album.resolveName(requireContext()) requireBinding().detailToolbar.title = album.resolveName(requireContext())
albumHeaderAdapter.setParent(album)
} }
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) { if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) {
detailAdapter.setPlaying(song, isPlaying) albumListAdapter.setPlaying(song, isPlaying)
} else { } else {
// Clear the ViewHolders if the mode isn't ALL_SONGS // Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.setPlaying(null, isPlaying) albumListAdapter.setPlaying(null, isPlaying)
} }
} }
@ -201,11 +212,11 @@ class AlbumDetailFragment :
if (unlikelyToBeNull(detailModel.currentAlbum.value) == item.album) { if (unlikelyToBeNull(detailModel.currentAlbum.value) == item.album) {
logD("Navigating to a song in this album") logD("Navigating to a song in this album")
scrollToAlbumSong(item) scrollToAlbumSong(item)
navModel.finishExploreNavigation() navModel.exploreNavigationItem.consume()
} else { } else {
logD("Navigating to another album") logD("Navigating to another album")
findNavController() findNavController()
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.uid)) .navigateSafe(AlbumDetailFragmentDirections.actionShowAlbum(item.album.uid))
} }
} }
@ -215,11 +226,11 @@ class AlbumDetailFragment :
if (unlikelyToBeNull(detailModel.currentAlbum.value) == item) { if (unlikelyToBeNull(detailModel.currentAlbum.value) == item) {
logD("Navigating to the top of this album") logD("Navigating to the top of this album")
binding.detailRecycler.scrollToPosition(0) binding.detailRecycler.scrollToPosition(0)
navModel.finishExploreNavigation() navModel.exploreNavigationItem.consume()
} else { } else {
logD("Navigating to another album") logD("Navigating to another album")
findNavController() findNavController()
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.uid)) .navigateSafe(AlbumDetailFragmentDirections.actionShowAlbum(item.uid))
} }
} }
@ -227,7 +238,7 @@ class AlbumDetailFragment :
is Artist -> { is Artist -> {
logD("Navigating to another artist") logD("Navigating to another artist")
findNavController() findNavController()
.navigate(AlbumDetailFragmentDirections.actionShowArtist(item.uid)) .navigateSafe(AlbumDetailFragmentDirections.actionShowArtist(item.uid))
} }
null -> {} null -> {}
else -> error("Unexpected datatype: ${item::class.java}") else -> error("Unexpected datatype: ${item::class.java}")
@ -272,12 +283,12 @@ class AlbumDetailFragment :
} }
} }
private fun updateList(items: List<Item>) { private fun updateList(list: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF) albumListAdapter.update(list, detailModel.albumInstructions.consume())
} }
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelected(selected.toSet()) albumListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
} }
} }

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* ArtistDetailFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -24,16 +25,18 @@ import android.view.View
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter import org.oxycblt.auxio.detail.header.ArtistDetailHeaderAdapter
import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -42,19 +45,18 @@ import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.*
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* A [ListFragment] that shows information about an [Artist]. * A [ListFragment] that shows information about an [Artist].
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class ArtistDetailFragment : class ArtistDetailFragment :
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> { ListFragment<Music, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
@ -62,7 +64,8 @@ class ArtistDetailFragment :
// Information about what artist to display is initially within the navigation arguments // Information about what artist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an artist. // as a UID, as that is the only safe way to parcel an artist.
private val args: ArtistDetailFragmentArgs by navArgs() private val args: ArtistDetailFragmentArgs by navArgs()
private val detailAdapter = ArtistDetailAdapter(this) private val artistHeaderAdapter = ArtistDetailHeaderAdapter(this)
private val artistListAdapter = ArtistDetailListAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -89,7 +92,7 @@ class ArtistDetailFragment :
setOnMenuItemClickListener(this@ArtistDetailFragment) setOnMenuItemClickListener(this@ArtistDetailFragment)
} }
binding.detailRecycler.adapter = detailAdapter binding.detailRecycler.adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
@ -98,7 +101,7 @@ class ArtistDetailFragment :
collectImmediately(detailModel.artistList, ::updateList) collectImmediately(detailModel.artistList, ::updateList)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
} }
@ -106,6 +109,9 @@ class ArtistDetailFragment :
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.artistInstructions.consume()
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
@ -194,8 +200,8 @@ class ArtistDetailFragment :
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
requireBinding().detailToolbar.title = artist.resolveName(requireContext()) requireBinding().detailToolbar.title = artist.resolveName(requireContext())
artistHeaderAdapter.setParent(artist)
} }
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
@ -210,7 +216,7 @@ class ArtistDetailFragment :
else -> null else -> null
} }
detailAdapter.setPlaying(playingItem, isPlaying) artistListAdapter.setPlaying(playingItem, isPlaying)
} }
private fun handleNavigation(item: Music?) { private fun handleNavigation(item: Music?) {
@ -221,14 +227,14 @@ class ArtistDetailFragment :
is Song -> { is Song -> {
logD("Navigating to another album") logD("Navigating to another album")
findNavController() findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.uid)) .navigateSafe(ArtistDetailFragmentDirections.actionShowAlbum(item.album.uid))
} }
// Launch a new detail view for an album, even if it is part of // Launch a new detail view for an album, even if it is part of
// this artist. // this artist.
is Album -> { is Album -> {
logD("Navigating to another album") logD("Navigating to another album")
findNavController() findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.uid)) .navigateSafe(ArtistDetailFragmentDirections.actionShowAlbum(item.uid))
} }
// If the artist that should be navigated to is this artist, then // If the artist that should be navigated to is this artist, then
// scroll back to the top. Otherwise launch a new detail view. // scroll back to the top. Otherwise launch a new detail view.
@ -236,11 +242,11 @@ class ArtistDetailFragment :
if (item.uid == detailModel.currentArtist.value?.uid) { if (item.uid == detailModel.currentArtist.value?.uid) {
logD("Navigating to the top of this artist") logD("Navigating to the top of this artist")
binding.detailRecycler.scrollToPosition(0) binding.detailRecycler.scrollToPosition(0)
navModel.finishExploreNavigation() navModel.exploreNavigationItem.consume()
} else { } else {
logD("Navigating to another artist") logD("Navigating to another artist")
findNavController() findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowArtist(item.uid)) .navigateSafe(ArtistDetailFragmentDirections.actionShowArtist(item.uid))
} }
} }
null -> {} null -> {}
@ -248,12 +254,12 @@ class ArtistDetailFragment :
} }
} }
private fun updateList(items: List<Item>) { private fun updateList(list: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF) artistListAdapter.update(list, detailModel.artistInstructions.consume())
} }
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelected(selected.toSet()) artistListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
} }
} }

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* DetailAppBarLayout.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* DetailViewModel.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -29,10 +30,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.recycler.SortHeader import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.AudioInfo import org.oxycblt.auxio.music.metadata.AudioInfo
import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.Disc
@ -44,6 +46,7 @@ import org.oxycblt.auxio.util.*
/** /**
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the * [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
* current item they are showing, sub-data to display, and configuration. * current item they are showing, sub-data to display, and configuration.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@HiltViewModel @HiltViewModel
@ -79,6 +82,10 @@ constructor(
/** The current list data derived from [currentAlbum]. */ /** The current list data derived from [currentAlbum]. */
val albumList: StateFlow<List<Item>> val albumList: StateFlow<List<Item>>
get() = _albumList get() = _albumList
private val _albumInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [albumList] in the UI. */
val albumInstructions: Event<UpdateInstructions>
get() = _albumInstructions
/** The current [Sort] used for [Song]s in [albumList]. */ /** The current [Sort] used for [Song]s in [albumList]. */
var albumSongSort: Sort var albumSongSort: Sort
@ -86,7 +93,7 @@ constructor(
set(value) { set(value) {
musicSettings.albumSongSort = value musicSettings.albumSongSort = value
// Refresh the album list to reflect the new sort. // Refresh the album list to reflect the new sort.
currentAlbum.value?.let(::refreshAlbumList) currentAlbum.value?.let { refreshAlbumList(it, true) }
} }
// --- ARTIST --- // --- ARTIST ---
@ -99,6 +106,10 @@ constructor(
private val _artistList = MutableStateFlow(listOf<Item>()) private val _artistList = MutableStateFlow(listOf<Item>())
/** The current list derived from [currentArtist]. */ /** The current list derived from [currentArtist]. */
val artistList: StateFlow<List<Item>> = _artistList val artistList: StateFlow<List<Item>> = _artistList
private val _artistInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistList] in the UI. */
val artistInstructions: Event<UpdateInstructions>
get() = _artistInstructions
/** The current [Sort] used for [Song]s in [artistList]. */ /** The current [Sort] used for [Song]s in [artistList]. */
var artistSongSort: Sort var artistSongSort: Sort
@ -106,7 +117,7 @@ constructor(
set(value) { set(value) {
musicSettings.artistSongSort = value musicSettings.artistSongSort = value
// Refresh the artist list to reflect the new sort. // Refresh the artist list to reflect the new sort.
currentArtist.value?.let(::refreshArtistList) currentArtist.value?.let { refreshArtistList(it, true) }
} }
// --- GENRE --- // --- GENRE ---
@ -119,6 +130,10 @@ constructor(
private val _genreList = MutableStateFlow(listOf<Item>()) private val _genreList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentGenre]. */ /** The current list data derived from [currentGenre]. */
val genreList: StateFlow<List<Item>> = _genreList val genreList: StateFlow<List<Item>> = _genreList
private val _genreInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistList] in the UI. */
val genreInstructions: Event<UpdateInstructions>
get() = _genreInstructions
/** The current [Sort] used for [Song]s in [genreList]. */ /** The current [Sort] used for [Song]s in [genreList]. */
var genreSongSort: Sort var genreSongSort: Sort
@ -126,7 +141,7 @@ constructor(
set(value) { set(value) {
musicSettings.genreSongSort = value musicSettings.genreSongSort = value
// Refresh the genre list to reflect the new sort. // Refresh the genre list to reflect the new sort.
currentGenre.value?.let(::refreshGenreList) currentGenre.value?.let { refreshGenreList(it, true) }
} }
/** /**
@ -182,6 +197,7 @@ constructor(
/** /**
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and
* [songAudioInfo] will be updated to align with the new [Song]. * [songAudioInfo] will be updated to align with the new [Song].
*
* @param uid The UID of the [Song] to load. Must be valid. * @param uid The UID of the [Song] to load. Must be valid.
*/ */
fun setSongUid(uid: Music.UID) { fun setSongUid(uid: Music.UID) {
@ -196,6 +212,7 @@ constructor(
/** /**
* Set a new [currentAlbum] from it's [Music.UID]. If the [Music.UID] differs, [currentAlbum] * Set a new [currentAlbum] from it's [Music.UID]. If the [Music.UID] differs, [currentAlbum]
* and [albumList] will be updated to align with the new [Album]. * and [albumList] will be updated to align with the new [Album].
*
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid. * @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
*/ */
fun setAlbumUid(uid: Music.UID) { fun setAlbumUid(uid: Music.UID) {
@ -210,6 +227,7 @@ constructor(
/** /**
* Set a new [currentArtist] from it's [Music.UID]. If the [Music.UID] differs, [currentArtist] * Set a new [currentArtist] from it's [Music.UID]. If the [Music.UID] differs, [currentArtist]
* and [artistList] will be updated to align with the new [Artist]. * and [artistList] will be updated to align with the new [Artist].
*
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid. * @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
*/ */
fun setArtistUid(uid: Music.UID) { fun setArtistUid(uid: Music.UID) {
@ -224,6 +242,7 @@ constructor(
/** /**
* Set a new [currentGenre] from it's [Music.UID]. If the [Music.UID] differs, [currentGenre] * Set a new [currentGenre] from it's [Music.UID]. If the [Music.UID] differs, [currentGenre]
* and [genreList] will be updated to align with the new album. * and [genreList] will be updated to align with the new album.
*
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid. * @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
*/ */
fun setGenreUid(uid: Music.UID) { fun setGenreUid(uid: Music.UID) {
@ -237,10 +256,6 @@ constructor(
private fun <T : Music> requireMusic(uid: Music.UID) = musicRepository.library?.find<T>(uid) private fun <T : Music> requireMusic(uid: Music.UID) = musicRepository.library?.find<T>(uid)
/**
* Start a new job to load a given [Song]'s [AudioInfo]. Result is pushed to [songAudioInfo].
* @param song The song to load.
*/
private fun refreshAudioInfo(song: Song) { private fun refreshAudioInfo(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI. // Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel() currentSongJob?.cancel()
@ -253,10 +268,17 @@ constructor(
} }
} }
private fun refreshAlbumList(album: Album) { private fun refreshAlbumList(album: Album, replace: Boolean = false) {
logD("Refreshing album data") logD("Refreshing album data")
val data = mutableListOf<Item>(album) val list = mutableListOf<Item>()
data.add(SortHeader(R.string.lbl_songs)) list.add(SortHeader(R.string.lbl_songs))
val instructions =
if (replace) {
// Intentional so that the header item isn't replaced with the songs
UpdateInstructions.Replace(list.size)
} else {
UpdateInstructions.Diff
}
// To create a good user experience regarding disc numbers, we group the album's // To create a good user experience regarding disc numbers, we group the album's
// songs up by disc and then delimit the groups by a disc header. // songs up by disc and then delimit the groups by a disc header.
@ -266,20 +288,21 @@ constructor(
if (byDisc.size > 1) { if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers") logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) { for (entry in byDisc.entries) {
data.add(entry.key) list.add(entry.key)
data.addAll(entry.value) list.addAll(entry.value)
} }
} else { } else {
// Album only has one disc, don't add any redundant headers // Album only has one disc, don't add any redundant headers
data.addAll(songs) list.addAll(songs)
} }
_albumList.value = data _albumInstructions.put(instructions)
_albumList.value = list
} }
private fun refreshArtistList(artist: Artist) { private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
logD("Refreshing artist data") logD("Refreshing artist data")
val data = mutableListOf<Item>(artist) val list = mutableListOf<Item>()
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums) val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
val byReleaseGroup = val byReleaseGroup =
@ -306,35 +329,50 @@ constructor(
logD("Release groups for this artist: ${byReleaseGroup.keys}") logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) { for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
data.add(BasicHeader(entry.key.headerTitleRes)) list.add(BasicHeader(entry.key.headerTitleRes))
data.addAll(entry.value) list.addAll(entry.value)
} }
// Artists may not be linked to any songs, only include a header entry if we have any. // Artists may not be linked to any songs, only include a header entry if we have any.
var instructions: UpdateInstructions = UpdateInstructions.Diff
if (artist.songs.isNotEmpty()) { if (artist.songs.isNotEmpty()) {
logD("Songs present in this artist, adding header") logD("Songs present in this artist, adding header")
data.add(SortHeader(R.string.lbl_songs)) list.add(SortHeader(R.string.lbl_songs))
data.addAll(artistSongSort.songs(artist.songs)) if (replace) {
// Intentional so that the header item isn't replaced with the songs
instructions = UpdateInstructions.Replace(list.size)
}
list.addAll(artistSongSort.songs(artist.songs))
} }
_artistList.value = data.toList() _artistInstructions.put(instructions)
_artistList.value = list.toList()
} }
private fun refreshGenreList(genre: Genre) { private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
logD("Refreshing genre data") logD("Refreshing genre data")
val data = mutableListOf<Item>(genre) val list = mutableListOf<Item>()
// Genre is guaranteed to always have artists and songs. // Genre is guaranteed to always have artists and songs.
data.add(BasicHeader(R.string.lbl_artists)) list.add(BasicHeader(R.string.lbl_artists))
data.addAll(genre.artists) list.addAll(genre.artists)
data.add(SortHeader(R.string.lbl_songs)) list.add(SortHeader(R.string.lbl_songs))
data.addAll(genreSongSort.songs(genre.songs)) val instructions =
_genreList.value = data if (replace) {
// Intentional so that the header item isn't replaced with the songs
UpdateInstructions.Replace(list.size)
} else {
UpdateInstructions.Diff
}
list.addAll(genreSongSort.songs(genre.songs))
_genreInstructions.put(instructions)
_genreList.value = list
} }
/** /**
* A simpler mapping of [ReleaseType] used for grouping and sorting songs. * A simpler mapping of [ReleaseType] used for grouping and sorting songs.
*
* @param headerTitleRes The title string resource to use for a header created out of an * @param headerTitleRes The title string resource to use for a header created out of an
* instance of this enum. * instance of this enum.
*/ */
private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) { private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
ALBUMS(R.string.lbl_albums), ALBUMS(R.string.lbl_albums),

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* GenreDetailFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -24,16 +25,18 @@ import android.view.View
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -43,19 +46,18 @@ import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.*
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* A [ListFragment] that shows information for a particular [Genre]. * A [ListFragment] that shows information for a particular [Genre].
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class GenreDetailFragment : class GenreDetailFragment :
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> { ListFragment<Music, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
@ -63,7 +65,8 @@ class GenreDetailFragment :
// Information about what genre to display is initially within the navigation arguments // Information about what genre to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an genre. // as a UID, as that is the only safe way to parcel an genre.
private val args: GenreDetailFragmentArgs by navArgs() private val args: GenreDetailFragmentArgs by navArgs()
private val detailAdapter = GenreDetailAdapter(this) private val genreHeaderAdapter = GenreDetailHeaderAdapter(this)
private val genreListAdapter = GenreDetailListAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -88,7 +91,7 @@ class GenreDetailFragment :
setOnMenuItemClickListener(this@GenreDetailFragment) setOnMenuItemClickListener(this@GenreDetailFragment)
} }
binding.detailRecycler.adapter = detailAdapter binding.detailRecycler.adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
@ -97,7 +100,7 @@ class GenreDetailFragment :
collectImmediately(detailModel.genreList, ::updateList) collectImmediately(detailModel.genreList, ::updateList)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
} }
@ -105,6 +108,9 @@ class GenreDetailFragment :
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.genreInstructions.consume()
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
@ -191,8 +197,8 @@ class GenreDetailFragment :
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
requireBinding().detailToolbar.title = genre.resolveName(requireContext()) requireBinding().detailToolbar.title = genre.resolveName(requireContext())
genreHeaderAdapter.setParent(genre)
} }
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
@ -204,7 +210,7 @@ class GenreDetailFragment :
if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) { if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) {
playingMusic = song playingMusic = song
} }
detailAdapter.setPlaying(playingMusic, isPlaying) genreListAdapter.setPlaying(playingMusic, isPlaying)
} }
private fun handleNavigation(item: Music?) { private fun handleNavigation(item: Music?) {
@ -212,31 +218,31 @@ class GenreDetailFragment :
is Song -> { is Song -> {
logD("Navigating to another song") logD("Navigating to another song")
findNavController() findNavController()
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.uid)) .navigateSafe(GenreDetailFragmentDirections.actionShowAlbum(item.album.uid))
} }
is Album -> { is Album -> {
logD("Navigating to another album") logD("Navigating to another album")
findNavController() findNavController()
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.uid)) .navigateSafe(GenreDetailFragmentDirections.actionShowAlbum(item.uid))
} }
is Artist -> { is Artist -> {
logD("Navigating to another artist") logD("Navigating to another artist")
findNavController() findNavController()
.navigate(GenreDetailFragmentDirections.actionShowArtist(item.uid)) .navigateSafe(GenreDetailFragmentDirections.actionShowArtist(item.uid))
} }
is Genre -> { is Genre -> {
navModel.finishExploreNavigation() navModel.exploreNavigationItem.consume()
} }
null -> {} null -> {}
} }
} }
private fun updateList(items: List<Item>) { private fun updateList(list: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF) genreListAdapter.update(list, detailModel.genreInstructions.consume())
} }
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelected(selected.toSet()) genreListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
} }
} }

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* ReadOnlyTextInput.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* SongDetailDialog.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -28,9 +29,9 @@ import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.detail.recycler.SongProperty import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.recycler.SongPropertyAdapter import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.metadata.AudioInfo import org.oxycblt.auxio.music.metadata.AudioInfo
@ -42,6 +43,7 @@ import org.oxycblt.auxio.util.concatLocalized
/** /**
* A [ViewBindingDialogFragment] that shows information about a Song. * A [ViewBindingDialogFragment] that shows information about a Song.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
@ -77,7 +79,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
if (info != null) { if (info != null) {
val context = requireContext() val context = requireContext()
detailAdapter.submitList( detailAdapter.update(
buildList { buildList {
add(SongProperty(R.string.lbl_name, song.zipName(context))) add(SongProperty(R.string.lbl_name, song.zipName(context)))
add(SongProperty(R.string.lbl_album, song.album.zipName(context))) add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
@ -102,7 +104,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
SongProperty( SongProperty(
R.string.lbl_relative_path, song.path.parent.resolveName(context))) R.string.lbl_relative_path, song.path.parent.resolveName(context)))
info.resolvedMimeType.resolveName(context)?.let { info.resolvedMimeType.resolveName(context)?.let {
SongProperty(R.string.lbl_format, it) add(SongProperty(R.string.lbl_format, it))
} }
add( add(
SongProperty( SongProperty(
@ -117,7 +119,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it))) R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it)))
} }
}, },
BasicListInstructions.REPLACE) UpdateInstructions.Replace(0))
} }
} }

View file

@ -0,0 +1,113 @@
/*
* Copyright (c) 2023 Auxio Project
* AlbumDetailHeaderAdapter.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.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Album] information.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Album, AlbumDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: AlbumDetailHeaderViewHolder, parent: Album) =
holder.bind(parent, listener)
/** An extended listener for [DetailHeaderAdapter] implementations. */
interface Listener : DetailHeaderAdapter.Listener {
/**
* Called when the artist name in the [Album] header was clicked, requesting navigation to
* it's parent artist.
*/
fun onNavigateToParentArtist()
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param album The new [Album] to bind.
* @param listener A [AlbumDetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(album: Album, listener: AlbumDetailHeaderAdapter.Listener) {
binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
binding.detailName.text = album.resolveName(binding.context)
// Artist name maps to the subhead text
binding.detailSubhead.apply {
text = album.artists.resolveNames(context)
// Add a QoL behavior where navigation to the artist will occur if the artist
// name is pressed.
setOnClickListener { listener.onNavigateToParentArtist() }
}
// 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?.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)
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
AlbumDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -0,0 +1,110 @@
/*
* Copyright (c) 2023 Auxio Project
* ArtistDetailHeaderAdapter.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.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Artist] information.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Artist, ArtistDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: ArtistDetailHeaderViewHolder, parent: Artist) =
holder.bind(parent, listener)
}
/**
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param artist The new [Artist] to bind.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(artist)
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
binding.detailName.text = artist.resolveName(binding.context)
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.apply {
isVisible = true
text = artist.genres.resolveNames(context)
}
// Song and album counts map to the info
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size))
// In the case that this header used to he configured to have no songs,
// we want to reset the visibility of all information that was hidden.
binding.detailPlayButton.isVisible = true
binding.detailShuffleButton.isVisible = true
} else {
// The artist does not have any songs, so hide functionality that makes no sense.
// ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those.
binding.detailSubhead.isVisible = false
binding.detailInfo.text =
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
binding.detailPlayButton.isVisible = false
binding.detailShuffleButton.isVisible = false
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
ArtistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2023 Auxio Project
* DetailHeaderAdapter.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.detail.header
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.MusicParent
/**
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder> :
RecyclerView.Adapter<VH>() {
private var currentParent: T? = null
final override fun getItemCount() = 1
final override fun onBindViewHolder(holder: VH, position: Int) =
onBindHeader(holder, requireNotNull(currentParent))
/**
* Bind the created header [RecyclerView.ViewHolder] with the current [parent].
*
* @param holder The [RecyclerView.ViewHolder] to bind.
* @param parent The current [MusicParent] to bind.
*/
abstract fun onBindHeader(holder: VH, parent: T)
/**
* Update the [MusicParent] shown in the header.
*
* @param parent The new [MusicParent] to show.
*/
fun setParent(parent: T) {
currentParent = parent
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
}
/** An extended listener for [DetailHeaderAdapter] implementations. */
interface Listener {
/**
* Called when the play button in a detail header is pressed, requesting that the current
* item should be played.
*/
fun onPlay()
/**
* Called when the shuffle button in a detail header is pressed, requesting that the current
* item should be shuffled
*/
fun onShuffle()
}
private companion object {
val PAYLOAD_UPDATE_HEADER = Any()
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright (c) 2023 Auxio Project
* GenreDetailHeaderAdapter.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.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Genre] information.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Genre, GenreDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: GenreDetailHeaderViewHolder, parent: Genre) =
holder.bind(parent, listener)
}
/**
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param genre The new [Genre] to bind.
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
*/
fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(genre)
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
binding.detailName.text = genre.resolveName(binding.context)
// Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song count of the genre maps to the info text.
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
GenreDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AlbumDetailListAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -15,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.detail.recycler package org.oxycblt.auxio.detail.list
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -25,7 +26,6 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
@ -33,36 +33,22 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.areRawNamesTheSame
import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
* An [DetailAdapter] implementing the header and sub-items for the [Album] detail view. * An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
* @param listener A [Listener] to bind interactions to. *
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { class AlbumDetailListAdapter(private val listener: Listener<Song>) :
/** DetailListAdapter(listener, DIFF_CALLBACK) {
* An extension to [DetailAdapter.Listener] that enables interactions specific to the album
* detail view.
*/
interface Listener : DetailAdapter.Listener<Song> {
/**
* Called when the artist name in the [Album] header was clicked, requesting navigation to
* it's parent artist.
*/
fun onNavigateToParentArtist()
}
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (getItem(position)) { when (getItem(position)) {
// Support the Album header, sub-headers for each disc, and special album songs. // Support sub-headers for each disc, and special album songs.
is Album -> AlbumDetailViewHolder.VIEW_TYPE
is Disc -> DiscViewHolder.VIEW_TYPE is Disc -> DiscViewHolder.VIEW_TYPE
is Song -> AlbumSongViewHolder.VIEW_TYPE is Song -> AlbumSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
@ -70,7 +56,6 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent)
DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent) DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent) AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
@ -79,7 +64,6 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position) super.onBindViewHolder(holder, position)
when (val item = getItem(position)) { when (val item = getItem(position)) {
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
is Disc -> (holder as DiscViewHolder).bind(item) is Disc -> (holder as DiscViewHolder).bind(item)
is Song -> (holder as AlbumSongViewHolder).bind(item, listener) is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
} }
@ -98,88 +82,16 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item) =
return when { when {
oldItem is Album && newItem is Album ->
AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Disc && newItem is Disc -> oldItem is Disc && newItem is Disc ->
DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song -> oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
// Fall back to DetailAdapter's differ to handle other headers. // Fall back to DetailAdapter's differ to handle other headers.
else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
} }
}
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
private class AlbumDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
* @param album The new [Album] to bind.
* @param listener A [AlbumDetailAdapter.Listener] to bind interactions to.
*/
fun bind(album: Album, listener: AlbumDetailAdapter.Listener) {
binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
binding.detailName.text = album.resolveName(binding.context)
// Artist name maps to the subhead text
binding.detailSubhead.apply {
text = album.artists.resolveNames(context)
// Add a QoL behavior where navigation to the artist will occur if the artist
// name is pressed.
setOnClickListener { listener.onNavigateToParentArtist() }
}
// 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?.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)
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM_DETAIL
/**
* Create a new instance.
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
oldItem.artists.areRawNamesTheSame(newItem.artists) &&
oldItem.dates == newItem.dates &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs &&
oldItem.releaseType == newItem.releaseType
} }
} }
} }
@ -187,12 +99,14 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
/** /**
* A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from] * A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from]
* to create an instance. * to create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) : private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param disc The new [disc] to bind. * @param disc The new [disc] to bind.
*/ */
fun bind(disc: Disc) { fun bind(disc: Disc) {
@ -209,6 +123,7 @@ private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) :
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
@ -227,12 +142,14 @@ private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) :
/** /**
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [from] to * A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [from] to
* create an instance. * create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) : private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) { SelectionIndicatorAdapter.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param song The new [Song] to bind. * @param song The new [Song] to bind.
* @param listener A [SelectableListListener] to bind interactions to. * @param listener A [SelectableListListener] to bind interactions to.
*/ */
@ -276,6 +193,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* ArtistDetailListAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -15,15 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.detail.recycler package org.oxycblt.auxio.detail.list
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
@ -32,20 +31,19 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
* A [DetailAdapter] implementing the header and sub-items for the [Artist] detail view. * A [DetailListAdapter] implementing the header and sub-items for the [Artist] detail view.
* @param listener A [DetailAdapter.Listener] to bind interactions to. *
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ArtistDetailAdapter(private val listener: Listener<Music>) : class ArtistDetailListAdapter(private val listener: Listener<Music>) :
DetailAdapter(listener, DIFF_CALLBACK) { DetailListAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (getItem(position)) { when (getItem(position)) {
// Support an artist header, and special artist albums/songs. // Support a special artist albums/songs.
is Artist -> ArtistDetailViewHolder.VIEW_TYPE
is Album -> ArtistAlbumViewHolder.VIEW_TYPE is Album -> ArtistAlbumViewHolder.VIEW_TYPE
is Song -> ArtistSongViewHolder.VIEW_TYPE is Song -> ArtistSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
@ -53,7 +51,6 @@ class ArtistDetailAdapter(private val listener: Listener<Music>) :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.from(parent)
ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.from(parent) ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.from(parent)
ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.from(parent) ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
@ -63,7 +60,6 @@ class ArtistDetailAdapter(private val listener: Listener<Music>) :
super.onBindViewHolder(holder, position) super.onBindViewHolder(holder, position)
// Re-binding an item with new data and not just a changed selection/playing state. // Re-binding an item with new data and not just a changed selection/playing state.
when (val item = getItem(position)) { when (val item = getItem(position)) {
is Artist -> (holder as ArtistDetailViewHolder).bind(item, listener)
is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener) is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener)
is Song -> (holder as ArtistSongViewHolder).bind(item, listener) is Song -> (holder as ArtistSongViewHolder).bind(item, listener)
} }
@ -81,93 +77,14 @@ class ArtistDetailAdapter(private val listener: Listener<Music>) :
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item) =
return when { when {
oldItem is Artist && newItem is Artist ->
ArtistDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(
oldItem, newItem)
oldItem is Album && newItem is Album -> oldItem is Album && newItem is Album ->
ArtistAlbumViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) ArtistAlbumViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song -> oldItem is Song && newItem is Song ->
ArtistSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) ArtistSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
} }
}
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
private class ArtistDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
* @param artist The new [Artist] to bind.
* @param listener A [DetailAdapter.Listener] to bind interactions to.
*/
fun bind(artist: Artist, listener: DetailAdapter.Listener<*>) {
binding.detailCover.bind(artist)
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
binding.detailName.text = artist.resolveName(binding.context)
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.apply {
isVisible = true
text = artist.genres.resolveNames(context)
}
// Song and album counts map to the info
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size))
// In the case that this header used to he configured to have no songs,
// we want to reset the visibility of all information that was hidden.
binding.detailPlayButton.isVisible = true
binding.detailShuffleButton.isVisible = true
} else {
// The artist does not have any songs, so hide functionality that makes no sense.
// ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those.
binding.detailSubhead.isVisible = false
binding.detailInfo.text =
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
binding.detailPlayButton.isVisible = false
binding.detailShuffleButton.isVisible = false
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_DETAIL
/**
* Create a new instance.
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Artist>() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.rawName == newItem.rawName &&
oldItem.genres.areRawNamesTheSame(newItem.genres) &&
oldItem.albums.size == newItem.albums.size &&
oldItem.songs.size == newItem.songs.size
} }
} }
} }
@ -175,12 +92,14 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
/** /**
* A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [from] to * A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [from] to
* create an instance. * create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
private class ArtistAlbumViewHolder private constructor(private val binding: ItemParentBinding) : private class ArtistAlbumViewHolder private constructor(private val binding: ItemParentBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) { SelectionIndicatorAdapter.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param album The new [Album] to bind. * @param album The new [Album] to bind.
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
@ -209,6 +128,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
@ -227,12 +147,14 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
/** /**
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Artist]. Use [from] to * A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Artist]. Use [from] to
* create an instance. * create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
private class ArtistSongViewHolder private constructor(private val binding: ItemSongBinding) : private class ArtistSongViewHolder private constructor(private val binding: ItemSongBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) { SelectionIndicatorAdapter.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param song The new [Song] to bind. * @param song The new [Song] to bind.
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
@ -258,6 +180,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* DetailListAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -15,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.detail.recycler package org.oxycblt.auxio.detail.list
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -36,18 +37,18 @@ import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
* A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters. * A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the
* detail views.
*
* @param listener A [Listener] to bind interactions to. * @param listener A [Listener] to bind interactions to.
* @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the * @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with.
* internal list.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class DetailAdapter( abstract class DetailListAdapter(
private val listener: Listener<*>, private val listener: Listener<*>,
diffCallback: DiffUtil.ItemCallback<Item> private val diffCallback: DiffUtil.ItemCallback<Item>
) : ) :
SelectionIndicatorAdapter<Item, BasicListInstructions, RecyclerView.ViewHolder>( SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback),
ListDiffer.Async(diffCallback)),
AuxioRecyclerView.SpanSizeLookup { AuxioRecyclerView.SpanSizeLookup {
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
@ -78,21 +79,8 @@ abstract class DetailAdapter(
return item is BasicHeader || item is SortHeader return item is BasicHeader || item is SortHeader
} }
/** An extended [SelectableListListener] for [DetailAdapter] implementations. */ /** An extended [SelectableListListener] for [DetailListAdapter] implementations. */
interface Listener<in T : Music> : SelectableListListener<T> { interface Listener<in T : Music> : SelectableListListener<T> {
// TODO: Split off into sub-listeners if a collapsing toolbar is implemented.
/**
* Called when the play button in a detail header is pressed, requesting that the current
* item should be played.
*/
fun onPlay()
/**
* Called when the shuffle button in a detail header is pressed, requesting that the current
* item should be shuffled
*/
fun onShuffle()
/** /**
* Called when the button in a [SortHeader] item is pressed, requesting that the sort menu * Called when the button in a [SortHeader] item is pressed, requesting that the sort menu
* should be opened. * should be opened.
@ -119,6 +107,7 @@ abstract class DetailAdapter(
/** /**
* A header variation that displays a button to open a sort menu. * A header variation that displays a button to open a sort menu.
*
* @param titleRes The string resource to use as the header title * @param titleRes The string resource to use as the header title
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@ -127,16 +116,18 @@ data class SortHeader(@StringRes override val titleRes: Int) : Header
/** /**
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [BasicHeader] that adds * A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [BasicHeader] that adds
* a button opening a menu for sorting. Use [from] to create an instance. * a button opening a menu for sorting. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param sortHeader The new [SortHeader] to bind. * @param sortHeader The new [SortHeader] to bind.
* @param listener An [DetailAdapter.Listener] to bind interactions to. * @param listener An [DetailListAdapter.Listener] to bind interactions to.
*/ */
fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener<*>) { fun bind(sortHeader: SortHeader, listener: DetailListAdapter.Listener<*>) {
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes) binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
binding.headerButton.apply { binding.headerButton.apply {
// Add a Tooltip based on the content description so that the purpose of this // Add a Tooltip based on the content description so that the purpose of this
@ -152,6 +143,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2021 Auxio Project
* GenreDetailListAdapter.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.detail.list
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
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.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
/**
* An [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
*
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailListAdapter(private val listener: Listener<Music>) :
DetailListAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Support generic Artist/Song items.
is Artist -> ArtistViewHolder.VIEW_TYPE
is Song -> SongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
when (val item = getItem(position)) {
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
is Song -> (holder as SongViewHolder).bind(item, listener)
}
}
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// Genre headers should be full-width in all configurations
return getItem(position) is Genre
}
private companion object {
val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Artist && newItem is Artist ->
ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
}
}
}
}

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* SongPropertyAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -15,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.detail.recycler package org.oxycblt.auxio.detail.list
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -23,21 +24,19 @@ import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.*
import org.oxycblt.auxio.list.adapter.DiffAdapter
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
* An adapter for [SongProperty] instances. * An adapter for [SongProperty] instances.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SongPropertyAdapter : class SongPropertyAdapter :
DiffAdapter<SongProperty, BasicListInstructions, SongPropertyViewHolder>( FlexibleListAdapter<SongProperty, SongPropertyViewHolder>(
ListDiffer.Blocking(SongPropertyViewHolder.DIFF_CALLBACK)) { SongPropertyViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongPropertyViewHolder.from(parent) SongPropertyViewHolder.from(parent)
@ -48,6 +47,7 @@ class SongPropertyAdapter :
/** /**
* A property entry for use in [SongPropertyAdapter]. * A property entry for use in [SongPropertyAdapter].
*
* @param name The contextual title to use for the property. * @param name The contextual title to use for the property.
* @param value The value of the property. * @param value The value of the property.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
@ -56,6 +56,7 @@ data class SongProperty(@StringRes val name: Int, val value: String) : Item
/** /**
* A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance. * A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SongPropertyViewHolder private constructor(private val binding: ItemSongPropertyBinding) : class SongPropertyViewHolder private constructor(private val binding: ItemSongPropertyBinding) :
@ -69,6 +70,7 @@ class SongPropertyViewHolder private constructor(private val binding: ItemSongPr
companion object { companion object {
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */

View file

@ -1,149 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
*
* 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.detail.recycler
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailBinding
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.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* An [DetailAdapter] implementing the header and sub-items for the [Genre] detail view.
* @param listener A [DetailAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailAdapter(private val listener: Listener<Music>) :
DetailAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Support the Genre header and generic Artist/Song items. There's nothing about
// a genre that will make the artists/songs specially formatted, so it doesn't matter
// what we use for their ViewHolders.
is Genre -> GenreDetailViewHolder.VIEW_TYPE
is Artist -> ArtistViewHolder.VIEW_TYPE
is Song -> SongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.from(parent)
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
SongViewHolder.VIEW_TYPE -> SongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
when (val item = getItem(position)) {
is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
is Song -> (holder as SongViewHolder).bind(item, listener)
}
}
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// Genre headers should be full-width in all configurations
return getItem(position) is Genre
}
private companion object {
val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Genre && newItem is Genre ->
GenreDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Artist && newItem is Artist ->
ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
}
}
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [from] to
* create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
private class GenreDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
* @param genre The new [Song] to bind.
* @param listener A [DetailAdapter.Listener] to bind interactions to.
*/
fun bind(genre: Genre, listener: DetailAdapter.Listener<*>) {
binding.detailCover.bind(genre)
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
binding.detailName.text = genre.resolveName(binding.context)
// Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song count of the genre maps to the info text.
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_GENRE_DETAIL
/**
* Create a new instance.
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Genre>() {
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.rawName == newItem.rawName &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs
}
}
}

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* EdgeFrameLayout.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -27,6 +28,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* A [FrameLayout] that automatically applies bottom insets. * A [FrameLayout] that automatically applies bottom insets.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class EdgeFrameLayout class EdgeFrameLayout

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* HomeFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -64,6 +65,7 @@ import org.oxycblt.auxio.util.*
/** /**
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation * The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
* to other views. * to other views.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
@ -153,11 +155,11 @@ class HomeFragment :
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() } binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(homeModel.shouldRecreate, ::handleRecreate) collect(homeModel.recreateTabs.flow, ::handleRecreate)
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab) collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.indexerState, ::updateIndexerState) collectImmediately(musicModel.indexerState, ::updateIndexerState)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
} }
@ -199,7 +201,7 @@ class HomeFragment :
R.id.action_search -> { R.id.action_search -> {
logD("Navigating to search") logD("Navigating to search")
setupAxisTransitions(MaterialSharedAxis.Z) setupAxisTransitions(MaterialSharedAxis.Z)
findNavController().navigate(HomeFragmentDirections.actionShowSearch()) findNavController().navigateSafe(HomeFragmentDirections.actionShowSearch())
} }
R.id.action_settings -> { R.id.action_settings -> {
logD("Navigating to settings") logD("Navigating to settings")
@ -328,18 +330,14 @@ class HomeFragment :
} }
} }
private fun handleRecreate(recreate: Boolean) { private fun handleRecreate(recreate: Unit?) {
if (!recreate) { if (recreate == null) return
// Nothing to do
return
}
val binding = requireBinding() val binding = requireBinding()
// Move back to position zero, as there must be a tab there. // Move back to position zero, as there must be a tab there.
binding.homePager.currentItem = 0 binding.homePager.currentItem = 0
// Make sure tabs are set up to also follow the new ViewPager configuration. // Make sure tabs are set up to also follow the new ViewPager configuration.
setupPager(binding) setupPager(binding)
homeModel.finishRecreate() homeModel.recreateTabs.consume()
} }
private fun updateIndexerState(state: Indexer.State?) { private fun updateIndexerState(state: Indexer.State?) {
@ -456,7 +454,7 @@ class HomeFragment :
} }
setupAxisTransitions(MaterialSharedAxis.X) setupAxisTransitions(MaterialSharedAxis.X)
findNavController().navigate(action) findNavController().navigateSafe(action)
} }
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
@ -483,10 +481,11 @@ class HomeFragment :
/** /**
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance. * [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
*
* @param tabs The current tab configuration. This will define the [Fragment]s created. * @param tabs The current tab configuration. This will define the [Fragment]s created.
* @param fragmentManager The [FragmentManager] required by [FragmentStateAdapter]. * @param fragmentManager The [FragmentManager] required by [FragmentStateAdapter].
* @param lifecycleOwner The [LifecycleOwner], whose Lifecycle is required by * @param lifecycleOwner The [LifecycleOwner], whose Lifecycle is required by
* [FragmentStateAdapter]. * [FragmentStateAdapter].
*/ */
private class HomePagerAdapter( private class HomePagerAdapter(
private val tabs: List<MusicMode>, private val tabs: List<MusicMode>,

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* HomeModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* HomeSettings.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -28,6 +29,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* User configuration specific to the home UI. * User configuration specific to the home UI.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface HomeSettings : Settings<HomeSettings.Listener> { interface HomeSettings : Settings<HomeSettings.Listener> {

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* HomeViewModel.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -24,13 +25,17 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.model.Library import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* The ViewModel for managing the tab data and lists of the home view. * The ViewModel for managing the tab data and lists of the home view.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@HiltViewModel @HiltViewModel
@ -47,11 +52,19 @@ constructor(
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
val songsList: StateFlow<List<Song>> val songsList: StateFlow<List<Song>>
get() = _songsList get() = _songsList
private val _songsInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [songsList] in the UI. */
val songsInstructions: Event<UpdateInstructions>
get() = _songsInstructions
private val _albumsLists = MutableStateFlow(listOf<Album>()) private val _albumsLists = MutableStateFlow(listOf<Album>())
/** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */ /** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */
val albumsList: StateFlow<List<Album>> val albumsList: StateFlow<List<Album>>
get() = _albumsLists get() = _albumsLists
private val _albumsInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [albumsList] in the UI. */
val albumsInstructions: Event<UpdateInstructions>
get() = _albumsInstructions
private val _artistsList = MutableStateFlow(listOf<Artist>()) private val _artistsList = MutableStateFlow(listOf<Artist>())
/** /**
@ -61,11 +74,19 @@ constructor(
*/ */
val artistsList: MutableStateFlow<List<Artist>> val artistsList: MutableStateFlow<List<Artist>>
get() = _artistsList get() = _artistsList
private val _artistsInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [artistsList] in the UI. */
val artistsInstructions: Event<UpdateInstructions>
get() = _artistsInstructions
private val _genresList = MutableStateFlow(listOf<Genre>()) private val _genresList = MutableStateFlow(listOf<Genre>())
/** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */ /** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */
val genresList: StateFlow<List<Genre>> val genresList: StateFlow<List<Genre>>
get() = _genresList get() = _genresList
private val _genresInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [genresList] in the UI. */
val genresInstructions: Event<UpdateInstructions>
get() = _genresInstructions
/** The [MusicMode] to use when playing a [Song] from the UI. */ /** The [MusicMode] to use when playing a [Song] from the UI. */
val playbackMode: MusicMode val playbackMode: MusicMode
@ -82,13 +103,14 @@ constructor(
/** The [MusicMode] of the currently shown [Tab]. */ /** The [MusicMode] of the currently shown [Tab]. */
val currentTabMode: StateFlow<MusicMode> = _currentTabMode val currentTabMode: StateFlow<MusicMode> = _currentTabMode
private val _shouldRecreate = MutableStateFlow(false) private val _shouldRecreate = MutableEvent<Unit>()
/** /**
* A marker to re-create all library tabs, usually initiated by a settings change. When this * A marker to re-create all library tabs, usually initiated by a settings change. When this
* flag is true, all tabs (and their respective ViewPager2 fragments) will be re-created from * flag is true, all tabs (and their respective ViewPager2 fragments) will be re-created from
* scratch. * scratch.
*/ */
val shouldRecreate: StateFlow<Boolean> = _shouldRecreate val recreateTabs: Event<Unit>
get() = _shouldRecreate
private val _isFastScrolling = MutableStateFlow(false) private val _isFastScrolling = MutableStateFlow(false)
/** A marker for whether the user is fast-scrolling in the home view or not. */ /** A marker for whether the user is fast-scrolling in the home view or not. */
@ -108,10 +130,14 @@ constructor(
override fun onLibraryChanged(library: Library?) { override fun onLibraryChanged(library: Library?) {
if (library != null) { if (library != null) {
logD("Library changed, refreshing library") logD("Library changed, refreshing library")
// FIXME: Sort name setting changes result in incorrect list updates
// Get the each list of items in the library to use as our list data. // Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them. // Applying the preferred sorting to them.
_songsInstructions.put(UpdateInstructions.Diff)
_songsList.value = musicSettings.songSort.songs(library.songs) _songsList.value = musicSettings.songSort.songs(library.songs)
_albumsInstructions.put(UpdateInstructions.Diff)
_albumsLists.value = musicSettings.albumSort.albums(library.albums) _albumsLists.value = musicSettings.albumSort.albums(library.albums)
_artistsInstructions.put(UpdateInstructions.Diff)
_artistsList.value = _artistsList.value =
musicSettings.artistSort.artists( musicSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) { if (homeSettings.shouldHideCollaborators) {
@ -120,6 +146,7 @@ constructor(
} else { } else {
library.artists library.artists
}) })
_genresInstructions.put(UpdateInstructions.Diff)
_genresList.value = musicSettings.genreSort.genres(library.genres) _genresList.value = musicSettings.genreSort.genres(library.genres)
} }
} }
@ -127,7 +154,7 @@ constructor(
override fun onTabsChanged() { override fun onTabsChanged() {
// Tabs changed, update the current tabs and set up a re-create event. // Tabs changed, update the current tabs and set up a re-create event.
currentTabModes = makeTabModes() currentTabModes = makeTabModes()
_shouldRecreate.value = true _shouldRecreate.put(Unit)
} }
override fun onHideCollaboratorsChanged() { override fun onHideCollaboratorsChanged() {
@ -138,6 +165,7 @@ constructor(
/** /**
* Get the preferred [Sort] for a given [Tab]. * Get the preferred [Sort] for a given [Tab].
*
* @param tabMode The [MusicMode] of the [Tab] desired. * @param tabMode The [MusicMode] of the [Tab] desired.
* @return The [Sort] preferred for that [Tab] * @return The [Sort] preferred for that [Tab]
*/ */
@ -151,6 +179,7 @@ constructor(
/** /**
* Update the preferred [Sort] for the current [Tab]. Will update corresponding list. * Update the preferred [Sort] for the current [Tab]. Will update corresponding list.
*
* @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab]. * @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab].
*/ */
fun setSortForCurrentTab(sort: Sort) { fun setSortForCurrentTab(sort: Sort) {
@ -159,18 +188,22 @@ constructor(
when (_currentTabMode.value) { when (_currentTabMode.value) {
MusicMode.SONGS -> { MusicMode.SONGS -> {
musicSettings.songSort = sort musicSettings.songSort = sort
_songsInstructions.put(UpdateInstructions.Replace(0))
_songsList.value = sort.songs(_songsList.value) _songsList.value = sort.songs(_songsList.value)
} }
MusicMode.ALBUMS -> { MusicMode.ALBUMS -> {
musicSettings.albumSort = sort musicSettings.albumSort = sort
_albumsInstructions.put(UpdateInstructions.Replace(0))
_albumsLists.value = sort.albums(_albumsLists.value) _albumsLists.value = sort.albums(_albumsLists.value)
} }
MusicMode.ARTISTS -> { MusicMode.ARTISTS -> {
musicSettings.artistSort = sort musicSettings.artistSort = sort
_artistsInstructions.put(UpdateInstructions.Replace(0))
_artistsList.value = sort.artists(_artistsList.value) _artistsList.value = sort.artists(_artistsList.value)
} }
MusicMode.GENRES -> { MusicMode.GENRES -> {
musicSettings.genreSort = sort musicSettings.genreSort = sort
_genresInstructions.put(UpdateInstructions.Replace(0))
_genresList.value = sort.genres(_genresList.value) _genresList.value = sort.genres(_genresList.value)
} }
} }
@ -178,6 +211,7 @@ constructor(
/** /**
* Update [currentTabMode] to reflect a new ViewPager2 position * Update [currentTabMode] to reflect a new ViewPager2 position
*
* @param pagerPos The new position of the ViewPager2 instance. * @param pagerPos The new position of the ViewPager2 instance.
*/ */
fun synchronizeTabPosition(pagerPos: Int) { fun synchronizeTabPosition(pagerPos: Int) {
@ -185,16 +219,9 @@ constructor(
_currentTabMode.value = currentTabModes[pagerPos] _currentTabMode.value = currentTabModes[pagerPos]
} }
/**
* Mark the recreation process as complete.
* @see shouldRecreate
*/
fun finishRecreate() {
_shouldRecreate.value = false
}
/** /**
* Update whether the user is fast scrolling or not in the home view. * Update whether the user is fast scrolling or not in the home view.
*
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise. * @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/ */
fun setFastScrolling(isFastScrolling: Boolean) { fun setFastScrolling(isFastScrolling: Boolean) {
@ -204,8 +231,9 @@ constructor(
/** /**
* Create a list of [MusicMode]s representing a simpler version of the [Tab] configuration. * Create a list of [MusicMode]s representing a simpler version of the [Tab] configuration.
*
* @return A list of the [MusicMode]s for each visible [Tab] in the configuration, ordered in * @return A list of the [MusicMode]s for each visible [Tab] in the configuration, ordered in
* the same way as the configuration. * the same way as the configuration.
*/ */
private fun makeTabModes() = private fun makeTabModes() =
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.mode } homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.mode }

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* FastScrollPopupView.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -40,6 +41,7 @@ import org.oxycblt.auxio.util.isRtl
/** /**
* A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView * A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView
*
* @author Alexander Capehart (OxygenCobalt), Hai Zhang * @author Alexander Capehart (OxygenCobalt), Hai Zhang
*/ */
class FastScrollPopupView class FastScrollPopupView

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* FastScrollRecyclerView.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -49,7 +50,7 @@ import org.oxycblt.auxio.util.*
* *
* !!! MODIFICATIONS !!!: * !!! MODIFICATIONS !!!:
* - Scroller will no longer show itself on startup or relayouts, which looked unpleasant with * - Scroller will no longer show itself on startup or relayouts, which looked unpleasant with
* multiple views * multiple views
* - DefaultAnimationHelper and RecyclerViewHelper were merged into the class * - DefaultAnimationHelper and RecyclerViewHelper were merged into the class
* - FastScroller overlay was merged into RecyclerView instance * - FastScroller overlay was merged into RecyclerView instance
* - Removed FastScrollerBuilder * - Removed FastScrollerBuilder
@ -61,11 +62,10 @@ import org.oxycblt.auxio.util.*
* - Added drag listener * - Added drag listener
* - Added documentation * - Added documentation
* *
* TODO: Add vibration when popup changes
*
* TODO: Improve support for variably sized items (Re-back with library fast scroller?)
*
* @author Hai Zhang, Alexander Capehart (OxygenCobalt) * @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 class FastScrollRecyclerView
@JvmOverloads @JvmOverloads
@ -508,9 +508,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
interface PopupProvider { interface PopupProvider {
/** /**
* Get text to use in the popup at the specified position. * Get text to use in the popup at the specified position.
*
* @param pos The position in the list. * @param pos The position in the list.
* @return A [String] to use in the popup. Null if there is no applicable text for the popup * @return A [String] to use in the popup. Null if there is no applicable text for the popup
* at [pos]. * at [pos].
*/ */
fun getPopup(pos: Int): String? fun getPopup(pos: Int): String?
} }
@ -519,6 +520,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
interface Listener { interface Listener {
/** /**
* Called when the fast scrolling state changes. * Called when the fast scrolling state changes.
*
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise. * @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/ */
fun onFastScrollingChanged(isFastScrolling: Boolean) fun onFastScrollingChanged(isFastScrolling: Boolean)

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AlbumListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -32,8 +33,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
@ -46,6 +45,7 @@ import org.oxycblt.auxio.util.collectImmediately
/** /**
* A [ListFragment] that shows a list of [Album]s. * A [ListFragment] that shows a list of [Album]s.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
@ -75,7 +75,7 @@ class AlbumListFragment :
listener = this@AlbumListFragment listener = this@AlbumListFragment
} }
collectImmediately(homeModel.albumsList, ::updateList) collectImmediately(homeModel.albumsList, ::updateAlbums)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
} }
@ -94,11 +94,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) { return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> album.collationKey?.run { sourceString.first().uppercase() } is Sort.Mode.ByName -> album.sortName?.thumbString
// By Artist -> Use name of first artist // By Artist -> Use name of first artist
is Sort.Mode.ByArtist -> is Sort.Mode.ByArtist -> album.artists[0].sortName?.thumbString
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd) // Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) } is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
@ -139,8 +138,8 @@ class AlbumListFragment :
openMusicMenu(anchor, R.menu.menu_album_actions, item) openMusicMenu(anchor, R.menu.menu_album_actions, item)
} }
private fun updateList(albums: List<Album>) { private fun updateAlbums(albums: List<Album>) {
albumAdapter.submitList(albums, BasicListInstructions.REPLACE) albumAdapter.update(albums, homeModel.albumsInstructions.consume())
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
@ -154,11 +153,11 @@ class AlbumListFragment :
/** /**
* A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder]. * A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder].
*
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
private class AlbumAdapter(private val listener: SelectableListListener<Album>) : private class AlbumAdapter(private val listener: SelectableListListener<Album>) :
SelectionIndicatorAdapter<Album, BasicListInstructions, AlbumViewHolder>( SelectionIndicatorAdapter<Album, AlbumViewHolder>(AlbumViewHolder.DIFF_CALLBACK) {
ListDiffer.Blocking(AlbumViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumViewHolder.from(parent) AlbumViewHolder.from(parent)

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* ArtistListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -30,8 +31,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
@ -43,10 +42,12 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
/** /**
* A [ListFragment] that shows a list of [Artist]s. * A [ListFragment] that shows a list of [Artist]s.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
@ -73,7 +74,7 @@ class ArtistListFragment :
listener = this@ArtistListFragment listener = this@ArtistListFragment
} }
collectImmediately(homeModel.artistsList, ::updateList) collectImmediately(homeModel.artistsList, ::updateArtists)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
} }
@ -92,7 +93,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) { return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> artist.collationKey?.run { sourceString.first().uppercase() } is Sort.Mode.ByName -> artist.sortName?.thumbString
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false) is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
@ -117,8 +118,8 @@ class ArtistListFragment :
openMusicMenu(anchor, R.menu.menu_artist_actions, item) openMusicMenu(anchor, R.menu.menu_artist_actions, item)
} }
private fun updateList(artists: List<Artist>) { private fun updateArtists(artists: List<Artist>) {
artistAdapter.submitList(artists, BasicListInstructions.REPLACE) artistAdapter.update(artists, homeModel.artistsInstructions.consume().also { logD(it) })
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
@ -132,11 +133,11 @@ class ArtistListFragment :
/** /**
* A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder]. * A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder].
*
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
private class ArtistAdapter(private val listener: SelectableListListener<Artist>) : private class ArtistAdapter(private val listener: SelectableListListener<Artist>) :
SelectionIndicatorAdapter<Artist, BasicListInstructions, ArtistViewHolder>( SelectionIndicatorAdapter<Artist, ArtistViewHolder>(ArtistViewHolder.DIFF_CALLBACK) {
ListDiffer.Blocking(ArtistViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistViewHolder.from(parent) ArtistViewHolder.from(parent)

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* GenreListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -30,8 +31,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.GenreViewHolder import org.oxycblt.auxio.list.recycler.GenreViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
@ -43,9 +42,11 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
/** /**
* A [ListFragment] that shows a list of [Genre]s. * A [ListFragment] that shows a list of [Genre]s.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
@ -72,7 +73,7 @@ class GenreListFragment :
listener = this@GenreListFragment listener = this@GenreListFragment
} }
collectImmediately(homeModel.genresList, ::updateList) collectImmediately(homeModel.genresList, ::updateGenres)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
} }
@ -91,7 +92,7 @@ class GenreListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) { return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> genre.collationKey?.run { sourceString.first().uppercase() } is Sort.Mode.ByName -> genre.sortName?.thumbString
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@ -116,8 +117,8 @@ class GenreListFragment :
openMusicMenu(anchor, R.menu.menu_artist_actions, item) openMusicMenu(anchor, R.menu.menu_artist_actions, item)
} }
private fun updateList(artists: List<Genre>) { private fun updateGenres(genres: List<Genre>) {
genreAdapter.submitList(artists, BasicListInstructions.REPLACE) genreAdapter.update(genres, homeModel.genresInstructions.consume().also { logD(it) })
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
@ -131,11 +132,11 @@ class GenreListFragment :
/** /**
* A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder]. * A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder].
*
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
private class GenreAdapter(private val listener: SelectableListListener<Genre>) : private class GenreAdapter(private val listener: SelectableListListener<Genre>) :
SelectionIndicatorAdapter<Genre, BasicListInstructions, GenreViewHolder>( SelectionIndicatorAdapter<Genre, GenreViewHolder>(GenreViewHolder.DIFF_CALLBACK) {
ListDiffer.Blocking(GenreViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreViewHolder.from(parent) GenreViewHolder.from(parent)

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* SongListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -32,8 +33,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
@ -49,6 +48,7 @@ import org.oxycblt.auxio.util.collectImmediately
/** /**
* A [ListFragment] that shows a list of [Song]s. * A [ListFragment] that shows a list of [Song]s.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint
@ -78,7 +78,7 @@ class SongListFragment :
listener = this@SongListFragment listener = this@SongListFragment
} }
collectImmediately(homeModel.songsList, ::updateList) collectImmediately(homeModel.songsList, ::updateSongs)
collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -100,15 +100,13 @@ class SongListFragment :
// based off the names of the parent objects and not the child objects. // based off the names of the parent objects and not the child objects.
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) { return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
// Name -> Use name // Name -> Use name
is Sort.Mode.ByName -> song.collationKey?.run { sourceString.first().uppercase() } is Sort.Mode.ByName -> song.sortName?.thumbString
// Artist -> Use name of first artist // Artist -> Use name of first artist
is Sort.Mode.ByArtist -> is Sort.Mode.ByArtist -> song.album.artists[0].sortName?.thumbString
song.album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Album -> Use Album Name // Album -> Use Album Name
is Sort.Mode.ByAlbum -> is Sort.Mode.ByAlbum -> song.album.sortName?.thumbString
song.album.collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year // Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext()) is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
@ -146,8 +144,8 @@ class SongListFragment :
openMusicMenu(anchor, R.menu.menu_song_actions, item) openMusicMenu(anchor, R.menu.menu_song_actions, item)
} }
private fun updateList(songs: List<Song>) { private fun updateSongs(songs: List<Song>) {
songAdapter.submitList(songs, BasicListInstructions.REPLACE) songAdapter.update(songs, homeModel.songsInstructions.consume())
} }
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
@ -165,11 +163,11 @@ class SongListFragment :
/** /**
* A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder]. * A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder].
*
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
private class SongAdapter(private val listener: SelectableListListener<Song>) : private class SongAdapter(private val listener: SelectableListListener<Song>) :
SelectionIndicatorAdapter<Song, BasicListInstructions, SongViewHolder>( SelectionIndicatorAdapter<Song, SongViewHolder>(SongViewHolder.DIFF_CALLBACK) {
ListDiffer.Blocking(SongViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongViewHolder.from(parent) SongViewHolder.from(parent)

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* AdaptiveTabStrategy.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -27,6 +28,7 @@ import org.oxycblt.auxio.util.logD
/** /**
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations * A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
* depending on the screen configuration. * depending on the screen configuration.
*
* @param context [Context] required to obtain window information * @param context [Context] required to obtain window information
* @param tabs Current tab configuration from settings * @param tabs Current tab configuration from settings
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Tab.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -22,18 +23,21 @@ import org.oxycblt.auxio.util.logE
/** /**
* A representation of a library tab suitable for configuration. * A representation of a library tab suitable for configuration.
*
* @param mode The type of list in the home view this instance corresponds to. * @param mode The type of list in the home view this instance corresponds to.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
sealed class Tab(open val mode: MusicMode) { sealed class Tab(open val mode: MusicMode) {
/** /**
* A visible tab. This will be visible in the home and tab configuration views. * A visible tab. This will be visible in the home and tab configuration views.
*
* @param mode The type of list in the home view this instance corresponds to. * @param mode The type of list in the home view this instance corresponds to.
*/ */
data class Visible(override val mode: MusicMode) : Tab(mode) data class Visible(override val mode: MusicMode) : Tab(mode)
/** /**
* A visible tab. This will be visible in the tab configuration view, but not in the home view. * A visible tab. This will be visible in the tab configuration view, but not in the home view.
*
* @param mode The type of list in the home view this instance corresponds to. * @param mode The type of list in the home view this instance corresponds to.
*/ */
data class Invisible(override val mode: MusicMode) : Tab(mode) data class Invisible(override val mode: MusicMode) : Tab(mode)
@ -68,6 +72,7 @@ sealed class Tab(open val mode: MusicMode) {
/** /**
* Convert an array of [Tab]s into it's integer representation. * Convert an array of [Tab]s into it's integer representation.
*
* @param tabs The array of [Tab]s to convert * @param tabs The array of [Tab]s to convert
* @return An integer representation of the [Tab] array * @return An integer representation of the [Tab] array
*/ */
@ -93,6 +98,7 @@ sealed class Tab(open val mode: MusicMode) {
/** /**
* Convert a [Tab] integer representation into it's corresponding array of [Tab]s. * Convert a [Tab] integer representation into it's corresponding array of [Tab]s.
*
* @param intCode The integer representation of the [Tab]s. * @param intCode The integer representation of the [Tab]s.
* @return An array of [Tab]s corresponding to the sequence. * @return An array of [Tab]s corresponding to the sequence.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* TabAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -30,6 +31,7 @@ import org.oxycblt.auxio.util.inflater
/** /**
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration. * A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
*
* @param listener A [EditableListListener] for tab interactions. * @param listener A [EditableListListener] for tab interactions.
*/ */
class TabAdapter(private val listener: EditableListListener<Tab>) : class TabAdapter(private val listener: EditableListListener<Tab>) :
@ -46,6 +48,7 @@ class TabAdapter(private val listener: EditableListListener<Tab>) :
/** /**
* Immediately update the tab array. This should be used when initializing the list. * Immediately update the tab array. This should be used when initializing the list.
*
* @param newTabs The new array of tabs to show. * @param newTabs The new array of tabs to show.
*/ */
fun submitTabs(newTabs: Array<Tab>) { fun submitTabs(newTabs: Array<Tab>) {
@ -55,6 +58,7 @@ class TabAdapter(private val listener: EditableListListener<Tab>) :
/** /**
* Update a specific tab to the given value. * Update a specific tab to the given value.
*
* @param at The position of the tab to update. * @param at The position of the tab to update.
* @param tab The new tab. * @param tab The new tab.
*/ */
@ -66,6 +70,7 @@ class TabAdapter(private val listener: EditableListListener<Tab>) :
/** /**
* Swap two tabs with each other. * Swap two tabs with each other.
*
* @param a The position of the first tab to swap. * @param a The position of the first tab to swap.
* @param b The position of the second tab to swap. * @param b The position of the second tab to swap.
*/ */
@ -83,12 +88,14 @@ class TabAdapter(private val listener: EditableListListener<Tab>) :
/** /**
* A [RecyclerView.ViewHolder] that displays a [Tab]. Use [from] to create an instance. * A [RecyclerView.ViewHolder] that displays a [Tab]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class TabViewHolder private constructor(private val binding: ItemTabBinding) : class TabViewHolder private constructor(private val binding: ItemTabBinding) :
DialogRecyclerView.ViewHolder(binding.root) { DialogRecyclerView.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param tab The new [Tab] to bind. * @param tab The new [Tab] to bind.
* @param listener A [EditableListListener] to bind interactions to. * @param listener A [EditableListListener] to bind interactions to.
*/ */
@ -114,6 +121,7 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
companion object { companion object {
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* TabCustomizeDialog.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -34,6 +35,7 @@ import org.oxycblt.auxio.util.logD
/** /**
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration. * A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* TabDragCallback.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -23,6 +24,7 @@ import androidx.recyclerview.widget.RecyclerView
/** /**
* An [ItemTouchHelper.Callback] that implements dragging in the [TabAdapter]. * An [ItemTouchHelper.Callback] that implements dragging in the [TabAdapter].
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() { class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() {

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* BitmapProvider.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -55,14 +56,16 @@ constructor(
interface Target { interface Target {
/** /**
* Configure the [ImageRequest.Builder] to enable [Target]-specific configuration. * Configure the [ImageRequest.Builder] to enable [Target]-specific configuration.
*
* @param builder The [ImageRequest.Builder] that will be used to request the desired * @param builder The [ImageRequest.Builder] that will be used to request the desired
* [Bitmap]. * [Bitmap].
* @return The same [ImageRequest.Builder] in order to easily chain configuration methods. * @return The same [ImageRequest.Builder] in order to easily chain configuration methods.
*/ */
fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder
/** /**
* Called when the loading process is completed. * Called when the loading process is completed.
*
* @param bitmap The loaded bitmap, or null if the bitmap could not be loaded. * @param bitmap The loaded bitmap, or null if the bitmap could not be loaded.
*/ */
fun onCompleted(bitmap: Bitmap?) fun onCompleted(bitmap: Bitmap?)
@ -77,6 +80,7 @@ constructor(
/** /**
* Load the Album cover [Bitmap] from a [Song]. * Load the Album cover [Bitmap] from a [Song].
*
* @param song The song to load a [Bitmap] of it's album cover from. * @param song The song to load a [Bitmap] of it's album cover from.
* @param target The [Target] to deliver the [Bitmap] to asynchronously. * @param target The [Target] to deliver the [Bitmap] to asynchronously.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* CoverMode.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -21,6 +22,7 @@ import org.oxycblt.auxio.IntegerTable
/** /**
* Represents the options available for album cover loading. * Represents the options available for album cover loading.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
enum class CoverMode { enum class CoverMode {
@ -33,6 +35,7 @@ enum class CoverMode {
/** /**
* The integer representation of this instance. * The integer representation of this instance.
*
* @see fromIntCode * @see fromIntCode
*/ */
val intCode: Int val intCode: Int
@ -46,6 +49,7 @@ enum class CoverMode {
companion object { companion object {
/** /**
* Convert a [CoverMode] integer representation into an instance. * Convert a [CoverMode] integer representation into an instance.
*
* @param intCode An integer representation of a [CoverMode] * @param intCode An integer representation of a [CoverMode]
* @return The corresponding [CoverMode], or null if the [CoverMode] is invalid. * @return The corresponding [CoverMode], or null if the [CoverMode] is invalid.
* @see CoverMode.intCode * @see CoverMode.intCode

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* ImageGroup.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -48,9 +49,9 @@ import org.oxycblt.auxio.util.getInteger
* This class is primarily intended for list items. For other uses, [StyledImageView] is more * This class is primarily intended for list items. For other uses, [StyledImageView] is more
* suitable. * suitable.
* *
* TODO: Rework content descriptions here
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Rework content descriptions here
*/ */
class ImageGroup class ImageGroup
@JvmOverloads @JvmOverloads
@ -146,6 +147,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Bind a [Song] to the internal [StyledImageView]. * Bind a [Song] to the internal [StyledImageView].
*
* @param song The [Song] to bind to the view. * @param song The [Song] to bind to the view.
* @see StyledImageView.bind * @see StyledImageView.bind
*/ */
@ -153,6 +155,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Bind a [Album] to the internal [StyledImageView]. * Bind a [Album] to the internal [StyledImageView].
*
* @param album The [Album] to bind to the view. * @param album The [Album] to bind to the view.
* @see StyledImageView.bind * @see StyledImageView.bind
*/ */
@ -160,6 +163,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Bind a [Genre] to the internal [StyledImageView]. * Bind a [Genre] to the internal [StyledImageView].
*
* @param artist The [Artist] to bind to the view. * @param artist The [Artist] to bind to the view.
* @see StyledImageView.bind * @see StyledImageView.bind
*/ */
@ -167,6 +171,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Bind a [Genre] to the internal [StyledImageView]. * Bind a [Genre] to the internal [StyledImageView].
*
* @param genre The [Genre] to bind to the view. * @param genre The [Genre] to bind to the view.
* @see StyledImageView.bind * @see StyledImageView.bind
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* ImageModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -27,11 +28,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher import org.oxycblt.auxio.image.extractor.*
import org.oxycblt.auxio.image.extractor.ArtistImageFetcher
import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory
import org.oxycblt.auxio.image.extractor.GenreImageFetcher
import org.oxycblt.auxio.image.extractor.MusicKeyer
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* ImageSettings.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -27,6 +28,7 @@ import org.oxycblt.auxio.util.logD
/** /**
* User configuration specific to image loading. * User configuration specific to image loading.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface ImageSettings : Settings<ImageSettings.Listener> { interface ImageSettings : Settings<ImageSettings.Listener> {

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* PlaybackIndicatorView.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* StyledImageView.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -48,11 +49,10 @@ import org.oxycblt.auxio.util.getDrawableCompat
/** /**
* An [AppCompatImageView] with some additional styling, including: * An [AppCompatImageView] with some additional styling, including:
*
* - Tonal background * - Tonal background
* - Rounded corners based on user preferences * - Rounded corners based on user preferences
* - Built-in support for binding image data or using a static icon with the same styling as * - Built-in support for binding image data or using a static icon with the same styling as
* placeholder drawables. * placeholder drawables.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@ -97,34 +97,39 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Bind a [Song]'s album cover to this view, also updating the content description. * Bind a [Song]'s album cover to this view, also updating the content description.
*
* @param song The [Song] to bind. * @param song The [Song] to bind.
*/ */
fun bind(song: Song) = bindImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover) fun bind(song: Song) = bindImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover)
/** /**
* Bind an [Album]'s cover to this view, also updating the content description. * Bind an [Album]'s cover to this view, also updating the content description.
*
* @param album the [Album] to bind. * @param album the [Album] to bind.
*/ */
fun bind(album: Album) = bindImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover) fun bind(album: Album) = bindImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover)
/** /**
* Bind an [Artist]'s image to this view, also updating the content description. * Bind an [Artist]'s image to this view, also updating the content description.
*
* @param artist the [Artist] to bind. * @param artist the [Artist] to bind.
*/ */
fun bind(artist: Artist) = bindImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image) fun bind(artist: Artist) = bindImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
/** /**
* Bind an [Genre]'s image to this view, also updating the content description. * Bind an [Genre]'s image to this view, also updating the content description.
*
* @param genre the [Genre] to bind. * @param genre the [Genre] to bind.
*/ */
fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image) fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
/** /**
* Internally bind a [Music]'s image to this view. * Internally bind a [Music]'s image to this view.
*
* @param music The music to find. * @param music The music to find.
* @param errorRes The error drawable resource to use if the music cannot be loaded. * @param errorRes The error drawable resource to use if the music cannot be loaded.
* @param descRes The content description string resource to use. The resource must have one * @param descRes The content description string resource to use. The resource must have one
* field for the name of the [Music]. * field for the name of the [Music].
*/ */
private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) { private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
val request = val request =
@ -144,6 +149,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* A [Drawable] wrapper that re-styles the drawable to better align with the style of * A [Drawable] wrapper that re-styles the drawable to better align with the style of
* [StyledImageView]. * [StyledImageView].
*
* @param context [Context] required for initialization. * @param context [Context] required for initialization.
* @param inner The [Drawable] to wrap. * @param inner The [Drawable] to wrap.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Components.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -31,7 +32,6 @@ import javax.inject.Inject
import kotlin.math.min import kotlin.math.min
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -41,6 +41,7 @@ import org.oxycblt.auxio.music.Song
/** /**
* A [Keyer] implementation for [Music] data. * A [Keyer] implementation for [Music] data.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicKeyer : Keyer<Music> { class MusicKeyer : Keyer<Music> {
@ -56,16 +57,17 @@ class MusicKeyer : Keyer<Music> {
/** /**
* Generic [Fetcher] for [Album] covers. Works with both [Album] and [Song]. Use [SongFactory] or * Generic [Fetcher] for [Album] covers. Works with both [Album] and [Song]. Use [SongFactory] or
* [AlbumFactory] for instantiation. * [AlbumFactory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class AlbumCoverFetcher class AlbumCoverFetcher
private constructor( private constructor(
private val context: Context, private val context: Context,
private val imageSettings: ImageSettings, private val extractor: CoverExtractor,
private val album: Album private val album: Album
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? = override suspend fun fetch(): FetchResult? =
Covers.fetch(context, imageSettings, album)?.run { extractor.extract(album)?.run {
SourceResult( SourceResult(
source = ImageSource(source().buffer(), context), source = ImageSource(source().buffer(), context),
mimeType = null, mimeType = null,
@ -73,77 +75,79 @@ private constructor(
} }
/** A [Fetcher.Factory] implementation that works with [Song]s. */ /** A [Fetcher.Factory] implementation that works with [Song]s. */
class SongFactory @Inject constructor(private val imageSettings: ImageSettings) : class SongFactory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Song> { Fetcher.Factory<Song> {
override fun create(data: Song, options: Options, imageLoader: ImageLoader) = override fun create(data: Song, options: Options, imageLoader: ImageLoader) =
AlbumCoverFetcher(options.context, imageSettings, data.album) AlbumCoverFetcher(options.context, coverExtractor, data.album)
} }
/** A [Fetcher.Factory] implementation that works with [Album]s. */ /** A [Fetcher.Factory] implementation that works with [Album]s. */
class AlbumFactory @Inject constructor(private val imageSettings: ImageSettings) : class AlbumFactory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Album> { Fetcher.Factory<Album> {
override fun create(data: Album, options: Options, imageLoader: ImageLoader) = override fun create(data: Album, options: Options, imageLoader: ImageLoader) =
AlbumCoverFetcher(options.context, imageSettings, data) AlbumCoverFetcher(options.context, coverExtractor, data)
} }
} }
/** /**
* [Fetcher] for [Artist] images. Use [Factory] for instantiation. * [Fetcher] for [Artist] images. Use [Factory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ArtistImageFetcher class ArtistImageFetcher
private constructor( private constructor(
private val context: Context, private val context: Context,
private val imageSettings: ImageSettings, private val extractor: CoverExtractor,
private val size: Size, private val size: Size,
private val artist: Artist private val artist: Artist
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
// Pick the "most prominent" albums (i.e albums with the most songs) to show in the image. // Pick the "most prominent" albums (i.e albums with the most songs) to show in the image.
val albums = Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(artist.albums) val albums = Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(artist.albums)
val results = val results = albums.mapAtMostNotNull(4) { album -> extractor.extract(album) }
albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, imageSettings, album) }
return Images.createMosaic(context, results, size) return Images.createMosaic(context, results, size)
} }
/** [Fetcher.Factory] implementation. */ /** [Fetcher.Factory] implementation. */
class Factory @Inject constructor(private val imageSettings: ImageSettings) : class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Artist> { Fetcher.Factory<Artist> {
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) = override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
ArtistImageFetcher(options.context, imageSettings, options.size, data) ArtistImageFetcher(options.context, extractor, options.size, data)
} }
} }
/** /**
* [Fetcher] for [Genre] images. Use [Factory] for instantiation. * [Fetcher] for [Genre] images. Use [Factory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class GenreImageFetcher class GenreImageFetcher
private constructor( private constructor(
private val context: Context, private val context: Context,
private val imageSettings: ImageSettings, private val extractor: CoverExtractor,
private val size: Size, private val size: Size,
private val genre: Genre private val genre: Genre
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
val results = genre.albums.mapAtMostNotNull(4) { Covers.fetch(context, imageSettings, it) } val results = genre.albums.mapAtMostNotNull(4) { album -> extractor.extract(album) }
return Images.createMosaic(context, results, size) return Images.createMosaic(context, results, size)
} }
/** [Fetcher.Factory] implementation. */ /** [Fetcher.Factory] implementation. */
class Factory @Inject constructor(private val imageSettings: ImageSettings) : class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Genre> { Fetcher.Factory<Genre> {
override fun create(data: Genre, options: Options, imageLoader: ImageLoader) = override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
GenreImageFetcher(options.context, imageSettings, options.size, data) GenreImageFetcher(options.context, extractor, options.size, data)
} }
} }
/** /**
* Map at most N [T] items a collection into a collection of [R], ignoring [T] that cannot be * Map at most N [T] items a collection into a collection of [R], ignoring [T] that cannot be
* transformed into [R]. * transformed into [R].
*
* @param n The maximum amount of items to map. * @param n The maximum amount of items to map.
* @param transform The function that transforms data [T] from the original list into data [R] in * @param transform The function that transforms data [T] from the original list into data [R] in
* the new list. Can return null if the [T] cannot be transformed into an [R]. * the new list. Can return null if the [T] cannot be transformed into an [R].
* @return A new list of at most N non-null [R] items. * @return A new list of at most N non-null [R] items.
*/ */
private inline fun <T : Any, R : Any> Collection<T>.mapAtMostNotNull( private inline fun <T : Any, R : Any> Collection<T>.mapAtMostNotNull(

View file

@ -0,0 +1,130 @@
/*
* 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.content.Context
import android.media.MediaMetadataRetriever
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.flac.PictureFrame
import com.google.android.exoplayer2.metadata.id3.ApicFrame
import com.google.android.exoplayer2.source.MediaSource
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 org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
class CoverExtractor
@Inject
constructor(
@ApplicationContext private val context: Context,
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
suspend fun extract(album: Album): InputStream? =
try {
when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> extractMediaStoreCover(album)
CoverMode.QUALITY -> extractQualityCover(album)
}
} catch (e: Exception) {
logW("Unable to extract album cover due to an error: $e")
null
}
private suspend fun extractQualityCover(album: Album) =
extractAospMetadataCover(album)
?: extractExoplayerCover(album) ?: extractMediaStoreCover(album)
private fun extractAospMetadataCover(album: Album): 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, album.songs[0].uri)
// 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
return embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
}
private suspend fun extractExoplayerCover(album: Album): InputStream? {
val tracks =
MetadataRetriever.retrieveMetadata(
mediaSourceFactory, MediaItem.fromUri(album.songs[0].uri))
.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
}
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) {
logD("Front cover found")
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
}
}
return stream
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun extractMediaStoreCover(album: Album) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
}

View file

@ -1,187 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
*
* 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.content.Context
import android.media.MediaMetadataRetriever
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.flac.PictureFrame
import com.google.android.exoplayer2.metadata.id3.ApicFrame
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.AudioOnlyExtractors
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/**
* Internal utilities for loading album covers.
* @author Alexander Capehart (OxygenCobalt).
*/
object Covers {
/**
* Fetch an album cover, respecting the current cover configuration.
* @param context [Context] required to load the image.
* @param imageSettings [ImageSettings] required to obtain configuration information.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null if the cover
* loading failed or should not occur.
*/
suspend fun fetch(context: Context, imageSettings: ImageSettings, album: Album): InputStream? {
return try {
when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album)
CoverMode.QUALITY -> fetchQualityCovers(context, album)
}
} catch (e: Exception) {
logW("Unable to extract album cover due to an error: $e")
null
}
}
/**
* Load an [Album] cover directly from one of it's Song files. This attempts the following in
* order:
* - [MediaMetadataRetriever], as it has the best support and speed.
* - ExoPlayer's [MetadataRetriever], as some devices (notably Samsung) can have broken
* [MediaMetadataRetriever] implementations.
* - MediaStore, as a last-ditch fallback if the format is really obscure.
*
* @param context [Context] required to load the image.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
*/
private suspend fun fetchQualityCovers(context: Context, album: Album) =
fetchAospMetadataCovers(context, album)
?: fetchExoplayerCover(context, album) ?: fetchMediaStoreCovers(context, album)
/**
* Loads an album cover with [MediaMetadataRetriever].
* @param context [Context] required to load the image.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
*/
private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? {
MediaMetadataRetriever().apply {
// 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, album.songs[0].uri)
// 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
return embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
}
}
/**
* Loads an [Album] cover with ExoPlayer's [MetadataRetriever].
* @param context [Context] required to load the image.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
*/
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
val uri = album.songs[0].uri
val future =
MetadataRetriever.retrieveMetadata(
DefaultMediaSourceFactory(context, AudioOnlyExtractors), MediaItem.fromUri(uri))
// future.get is a blocking call that makes us spin until the future is done.
// This is bad for a co-routine, as it prevents cancellation and by extension
// messes with the image loading process and causes annoying bugs.
// To fix this we wrap this around in a withContext call to make it suspend and make
// sure that the runner can do other coroutines.
@Suppress("BlockingMethodInNonBlockingContext")
val tracks =
withContext(Dispatchers.Default) {
try {
future.get()
} catch (e: Exception) {
null
}
}
if (tracks == null || tracks.isEmpty) {
// Unrecognized format. This is expected, as ExoPlayer only supports a
// subset of formats.
return null
}
// 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
}
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) {
logD("Front cover found")
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
}
}
return stream
}
/**
* Loads an [Album] cover from MediaStore.
* @param context [Context] required to load the image.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
*/
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? {
// Eliminate any chance that this blocking call might mess up the loading process
return withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(album.coverUri)
}
}
}

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* ErrorCrossfadeTransitionFactory.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -27,6 +28,7 @@ import coil.transition.TransitionTarget
/** /**
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results. * A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.
*
* @author Coil Team, Alexander Capehart (OxygenCobalt) * @author Coil Team, Alexander Capehart (OxygenCobalt)
*/ */
class ErrorCrossfadeTransitionFactory : Transition.Factory { class ErrorCrossfadeTransitionFactory : Transition.Factory {

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* Images.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -37,12 +38,14 @@ import okio.source
/** /**
* Utilities for constructing Artist and Genre images. * Utilities for constructing Artist and Genre images.
*
* @author Alexander Capehart (OxygenCobalt), Karim Abou Zeid * @author Alexander Capehart (OxygenCobalt), Karim Abou Zeid
*/ */
object Images { object Images {
/** /**
* Create a mosaic image from the given image [InputStream]s. Derived from phonograph: * Create a mosaic image from the given image [InputStream]s. Derived from phonograph:
* https://github.com/kabouzeid/Phonograph * https://github.com/kabouzeid/Phonograph
*
* @param context [Context] required to generate the mosaic. * @param context [Context] required to generate the mosaic.
* @param streams [InputStream]s of image data to create the mosaic out of. * @param streams [InputStream]s of image data to create the mosaic out of.
* @param size [Size] of the Mosaic to generate. * @param size [Size] of the Mosaic to generate.
@ -104,8 +107,9 @@ object Images {
/** /**
* Get an image dimension suitable to create a mosaic with. * Get an image dimension suitable to create a mosaic with.
*
* @return A pixel dimension derived from the given [Dimension] that will always be even, * @return A pixel dimension derived from the given [Dimension] that will always be even,
* allowing it to be sub-divided. * allowing it to be sub-divided.
*/ */
private fun Dimension.mosaicSize(): Int { private fun Dimension.mosaicSize(): Int {
val size = pxOrElse { 512 } val size = pxOrElse { 512 }

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* SquareFrameTransform.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -26,6 +27,7 @@ import kotlin.math.min
/** /**
* A transformation that performs a center crop-style transformation on an image. Allowing this * A transformation that performs a center crop-style transformation on an image. Allowing this
* behavior to be intrinsic without any view configuration. * behavior to be intrinsic without any view configuration.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SquareFrameTransform : Transformation { class SquareFrameTransform : Transformation {

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* Data.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -24,6 +25,7 @@ interface Item
/** /**
* A "header" used for delimiting groups of data. * A "header" used for delimiting groups of data.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Header : Item { interface Header : Item {
@ -33,6 +35,7 @@ interface Header : Item {
/** /**
* A basic header with no additional actions. * A basic header with no additional actions.
*
* @param titleRes The string resource used for the header's title. * @param titleRes The string resource used for the header's title.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* ListFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -36,6 +37,7 @@ import org.oxycblt.auxio.util.showToast
/** /**
* A Fragment containing a selectable list. * A Fragment containing a selectable list.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class ListFragment<in T : Music, VB : ViewBinding> : abstract class ListFragment<in T : Music, VB : ViewBinding> :
@ -52,6 +54,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
/** /**
* Called when [onClick] is called, but does not result in the item being selected. This more or * Called when [onClick] is called, but does not result in the item being selected. This more or
* less corresponds to an [onClick] implementation in a non-[ListFragment]. * less corresponds to an [onClick] implementation in a non-[ListFragment].
*
* @param item The [T] data of the item that was clicked. * @param item The [T] data of the item that was clicked.
*/ */
abstract fun onRealClick(item: T) abstract fun onRealClick(item: T)
@ -73,6 +76,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
/** /**
* Opens a menu in the context of a [Song]. This menu will be managed by the Fragment and closed * Opens a menu in the context of a [Song]. This menu will be managed by the Fragment and closed
* when the view is destroyed. If a menu is already opened, this call is ignored. * when the view is destroyed. If a menu is already opened, this call is ignored.
*
* @param anchor The [View] to anchor the menu to. * @param anchor The [View] to anchor the menu to.
* @param menuRes The resource of the menu to load. * @param menuRes The resource of the menu to load.
* @param song The [Song] to create the menu for. * @param song The [Song] to create the menu for.
@ -111,6 +115,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
/** /**
* Opens a menu in the context of a [Album]. This menu will be managed by the Fragment and * Opens a menu in the context of a [Album]. This menu will be managed by the Fragment and
* closed when the view is destroyed. If a menu is already opened, this call is ignored. * closed when the view is destroyed. If a menu is already opened, this call is ignored.
*
* @param anchor The [View] to anchor the menu to. * @param anchor The [View] to anchor the menu to.
* @param menuRes The resource of the menu to load. * @param menuRes The resource of the menu to load.
* @param album The [Album] to create the menu for. * @param album The [Album] to create the menu for.
@ -147,6 +152,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
/** /**
* Opens a menu in the context of a [Artist]. This menu will be managed by the Fragment and * Opens a menu in the context of a [Artist]. This menu will be managed by the Fragment and
* closed when the view is destroyed. If a menu is already opened, this call is ignored. * closed when the view is destroyed. If a menu is already opened, this call is ignored.
*
* @param anchor The [View] to anchor the menu to. * @param anchor The [View] to anchor the menu to.
* @param menuRes The resource of the menu to load. * @param menuRes The resource of the menu to load.
* @param artist The [Artist] to create the menu for. * @param artist The [Artist] to create the menu for.
@ -180,6 +186,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
/** /**
* Opens a menu in the context of a [Genre]. This menu will be managed by the Fragment and * Opens a menu in the context of a [Genre]. This menu will be managed by the Fragment and
* closed when the view is destroyed. If a menu is already opened, this call is ignored. * closed when the view is destroyed. If a menu is already opened, this call is ignored.
*
* @param anchor The [View] to anchor the menu to. * @param anchor The [View] to anchor the menu to.
* @param menuRes The resource of the menu to load. * @param menuRes The resource of the menu to load.
* @param genre The [Genre] to create the menu for. * @param genre The [Genre] to create the menu for.
@ -226,6 +233,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
/** /**
* Open a menu. This menu will be managed by the Fragment and closed when the view is destroyed. * Open a menu. This menu will be managed by the Fragment and closed when the view is destroyed.
* If a menu is already opened, this call is ignored. * If a menu is already opened, this call is ignored.
*
* @param anchor The [View] to anchor the menu to. * @param anchor The [View] to anchor the menu to.
* @param menuRes The resource of the menu to load. * @param menuRes The resource of the menu to load.
* @param block A block that is ran within [PopupMenu] that allows further configuration. * @param block A block that is ran within [PopupMenu] that allows further configuration.

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* Listeners.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -23,11 +24,13 @@ import androidx.recyclerview.widget.RecyclerView
/** /**
* A basic listener for list interactions. * A basic listener for list interactions.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface ClickableListListener<in T> { interface ClickableListListener<in T> {
/** /**
* Called when an item in the list is clicked. * Called when an item in the list is clicked.
*
* @param item The [T] item that was clicked. * @param item The [T] item that was clicked.
* @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked. * @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
*/ */
@ -35,10 +38,11 @@ interface ClickableListListener<in T> {
/** /**
* Binds this instance to a list item. * Binds this instance to a list item.
*
* @param item The [T] to bind this item to. * @param item The [T] to bind this item to.
* @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked. * @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
* @param bodyView The [View] containing the main body of the list item. Any click events on * @param bodyView The [View] containing the main body of the list item. Any click events on
* this [View] are routed to the listener. Defaults to the root view. * this [View] are routed to the listener. Defaults to the root view.
*/ */
fun bind(item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView) { fun bind(item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView) {
bodyView.setOnClickListener { onClick(item, viewHolder) } bodyView.setOnClickListener { onClick(item, viewHolder) }
@ -47,21 +51,24 @@ interface ClickableListListener<in T> {
/** /**
* An extension of [ClickableListListener] that enables list editing functionality. * An extension of [ClickableListListener] that enables list editing functionality.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface EditableListListener<in T> : ClickableListListener<T> { interface EditableListListener<in T> : ClickableListListener<T> {
/** /**
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged. * Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
*
* @param viewHolder The [RecyclerView.ViewHolder] that should start being dragged. * @param viewHolder The [RecyclerView.ViewHolder] that should start being dragged.
*/ */
fun onPickUp(viewHolder: RecyclerView.ViewHolder) fun onPickUp(viewHolder: RecyclerView.ViewHolder)
/** /**
* Binds this instance to a list item. * Binds this instance to a list item.
*
* @param item The [T] to bind this item to. * @param item The [T] to bind this item to.
* @param viewHolder The [RecyclerView.ViewHolder] to bind. * @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param bodyView The [View] containing the main body of the list item. Any click events on * @param bodyView The [View] containing the main body of the list item. Any click events on
* this [View] are routed to the listener. Defaults to the root view. * this [View] are routed to the listener. Defaults to the root view.
* @param dragHandle A touchable [View]. Any drag on this view will start a drag event. * @param dragHandle A touchable [View]. Any drag on this view will start a drag event.
*/ */
fun bind( fun bind(
@ -83,11 +90,13 @@ interface EditableListListener<in T> : ClickableListListener<T> {
/** /**
* An extension of [ClickableListListener] that enables menu and selection functionality. * An extension of [ClickableListListener] that enables menu and selection functionality.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface SelectableListListener<in T> : ClickableListListener<T> { interface SelectableListListener<in T> : ClickableListListener<T> {
/** /**
* Called when an item in the list requests that a menu related to it should be opened. * Called when an item in the list requests that a menu related to it should be opened.
*
* @param item The [T] item to open a menu for. * @param item The [T] item to open a menu for.
* @param anchor The [View] to anchor the menu to. * @param anchor The [View] to anchor the menu to.
*/ */
@ -95,16 +104,18 @@ interface SelectableListListener<in T> : ClickableListListener<T> {
/** /**
* Called when an item in the list requests that it be selected. * Called when an item in the list requests that it be selected.
*
* @param item The [T] item to select. * @param item The [T] item to select.
*/ */
fun onSelect(item: T) fun onSelect(item: T)
/** /**
* Binds this instance to a list item. * Binds this instance to a list item.
*
* @param item The [T] to bind this item to. * @param item The [T] to bind this item to.
* @param viewHolder The [RecyclerView.ViewHolder] to bind. * @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param bodyView The [View] containing the main body of the list item. Any click events on * @param bodyView The [View] containing the main body of the list item. Any click events on
* this [View] are routed to the listener. Defaults to the root view. * this [View] are routed to the listener. Defaults to the root view.
* @param menuButton A clickable [View]. Any click events on this [View] will open a menu. * @param menuButton A clickable [View]. Any click events on this [View] will open a menu.
*/ */
fun bind( fun bind(

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* Sort.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -38,6 +39,7 @@ import org.oxycblt.auxio.music.metadata.Disc
data class Sort(val mode: Mode, val direction: Direction) { data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Create a new [Sort] with the same [mode], but a different [Direction]. * Create a new [Sort] with the same [mode], but a different [Direction].
*
* @param direction The new [Direction] to sort in. * @param direction The new [Direction] to sort in.
* @return A new sort with the same mode, but with the new [Direction] value applied. * @return A new sort with the same mode, but with the new [Direction] value applied.
*/ */
@ -45,6 +47,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Create a new [Sort] with the same [direction] value, but different [mode] value. * Create a new [Sort] with the same [direction] value, but different [mode] value.
*
* @param mode Tbe new mode to use for the Sort. * @param mode Tbe new mode to use for the Sort.
* @return A new sort with the same [direction] value, but with the new [mode] applied. * @return A new sort with the same [direction] value, but with the new [mode] applied.
*/ */
@ -52,6 +55,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort a list of [Song]s. * Sort a list of [Song]s.
*
* @param songs The list of [Song]s. * @param songs The list of [Song]s.
* @return A new list of [Song]s sorted by this [Sort]'s configuration. * @return A new list of [Song]s sorted by this [Sort]'s configuration.
*/ */
@ -63,6 +67,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort a list of [Album]s. * Sort a list of [Album]s.
*
* @param albums The list of [Album]s. * @param albums The list of [Album]s.
* @return A new list of [Album]s sorted by this [Sort]'s configuration. * @return A new list of [Album]s sorted by this [Sort]'s configuration.
*/ */
@ -74,6 +79,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort a list of [Artist]s. * Sort a list of [Artist]s.
*
* @param artists The list of [Artist]s. * @param artists The list of [Artist]s.
* @return A new list of [Artist]s sorted by this [Sort]'s configuration. * @return A new list of [Artist]s sorted by this [Sort]'s configuration.
*/ */
@ -85,6 +91,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort a list of [Genre]s. * Sort a list of [Genre]s.
*
* @param genres The list of [Genre]s. * @param genres The list of [Genre]s.
* @return A new list of [Genre]s sorted by this [Sort]'s configuration. * @return A new list of [Genre]s sorted by this [Sort]'s configuration.
*/ */
@ -96,6 +103,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration. * Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
*
* @param songs The [Song]s to sort. * @param songs The [Song]s to sort.
*/ */
private fun songsInPlace(songs: MutableList<out Song>) { private fun songsInPlace(songs: MutableList<out Song>) {
@ -104,6 +112,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort a *mutable* list of [Album]s in-place using this [Sort]'s configuration. * Sort a *mutable* list of [Album]s in-place using this [Sort]'s configuration.
*
* @param albums The [Album]s to sort. * @param albums The [Album]s to sort.
*/ */
private fun albumsInPlace(albums: MutableList<out Album>) { private fun albumsInPlace(albums: MutableList<out Album>) {
@ -112,6 +121,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort a *mutable* list of [Artist]s in-place using this [Sort]'s configuration. * Sort a *mutable* list of [Artist]s in-place using this [Sort]'s configuration.
*
* @param artists The [Album]s to sort. * @param artists The [Album]s to sort.
*/ */
private fun artistsInPlace(artists: MutableList<out Artist>) { private fun artistsInPlace(artists: MutableList<out Artist>) {
@ -120,6 +130,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort a *mutable* list of [Genre]s in-place using this [Sort]'s configuration. * Sort a *mutable* list of [Genre]s in-place using this [Sort]'s configuration.
*
* @param genres The [Genre]s to sort. * @param genres The [Genre]s to sort.
*/ */
private fun genresInPlace(genres: MutableList<out Genre>) { private fun genresInPlace(genres: MutableList<out Genre>) {
@ -128,6 +139,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* The integer representation of this instance. * The integer representation of this instance.
*
* @see fromIntCode * @see fromIntCode
*/ */
val intCode: Int val intCode: Int
@ -150,6 +162,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Get a [Comparator] that sorts [Song]s according to this [Mode]. * Get a [Comparator] that sorts [Song]s according to this [Mode].
*
* @param direction The direction to sort in. * @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Song] list according to this [Mode]. * @return A [Comparator] that can be used to sort a [Song] list according to this [Mode].
*/ */
@ -159,6 +172,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Get a [Comparator] that sorts [Album]s according to this [Mode]. * Get a [Comparator] that sorts [Album]s according to this [Mode].
*
* @param direction The direction to sort in. * @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Album] list according to this [Mode]. * @return A [Comparator] that can be used to sort a [Album] list according to this [Mode].
*/ */
@ -168,6 +182,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Return a [Comparator] that sorts [Artist]s according to this [Mode]. * Return a [Comparator] that sorts [Artist]s according to this [Mode].
*
* @param direction The direction to sort in. * @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Artist] list according to this [Mode]. * @return A [Comparator] that can be used to sort a [Artist] list according to this [Mode].
*/ */
@ -177,6 +192,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Return a [Comparator] that sorts [Genre]s according to this [Mode]. * Return a [Comparator] that sorts [Genre]s according to this [Mode].
*
* @param direction The direction to sort in. * @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode]. * @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
*/ */
@ -186,7 +202,8 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort by the item's name. * Sort by the item's name.
* @see Music.collationKey *
* @see Music.sortName
*/ */
object ByName : Mode() { object ByName : Mode() {
override val intCode: Int override val intCode: Int
@ -210,6 +227,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort by the [Album] of an item. Only available for [Song]s. * Sort by the [Album] of an item. Only available for [Song]s.
*
* @see Album.collationKey * @see Album.collationKey
*/ */
object ByAlbum : Mode() { object ByAlbum : Mode() {
@ -229,7 +247,8 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort by the [Artist] name of an item. Only available for [Song] and [Album]. * Sort by the [Artist] name of an item. Only available for [Song] and [Album].
* @see Artist.collationKey *
* @see Artist.sortName
*/ */
object ByArtist : Mode() { object ByArtist : Mode() {
override val intCode: Int override val intCode: Int
@ -256,6 +275,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort by the [Date] of an item. Only available for [Song] and [Album]. * Sort by the [Date] of an item. Only available for [Song] and [Album].
*
* @see Song.date * @see Song.date
* @see Album.dates * @see Album.dates
*/ */
@ -308,6 +328,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort by the amount of songs an item contains. Only available for [MusicParent]s. * Sort by the amount of songs an item contains. Only available for [MusicParent]s.
*
* @see MusicParent.songs * @see MusicParent.songs
*/ */
object ByCount : Mode() { object ByCount : Mode() {
@ -333,6 +354,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort by the disc number of an item. Only available for [Song]s. * Sort by the disc number of an item. Only available for [Song]s.
*
* @see Song.disc * @see Song.disc
*/ */
object ByDisc : Mode() { object ByDisc : Mode() {
@ -351,6 +373,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort by the track number of an item. Only available for [Song]s. * Sort by the track number of an item. Only available for [Song]s.
*
* @see Song.track * @see Song.track
*/ */
object ByTrack : Mode() { object ByTrack : Mode() {
@ -369,6 +392,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort by the date an item was added. Only supported by [Song]s and [Album]s. * Sort by the date an item was added. Only supported by [Song]s and [Album]s.
*
* @see Song.dateAdded * @see Song.dateAdded
* @see Album.dates * @see Album.dates
*/ */
@ -391,6 +415,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Utility function to create a [Comparator] in a dynamic way determined by [direction]. * Utility function to create a [Comparator] in a dynamic way determined by [direction].
*
* @param direction The [Direction] to sort in. * @param direction The [Direction] to sort in.
* @see compareBy * @see compareBy
* @see compareByDescending * @see compareByDescending
@ -406,6 +431,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Utility function to create a [Comparator] in a dynamic way determined by [direction] * Utility function to create a [Comparator] in a dynamic way determined by [direction]
*
* @param direction The [Direction] to sort in. * @param direction The [Direction] to sort in.
* @param comparator A [Comparator] to wrap. * @param comparator A [Comparator] to wrap.
* @return A new [Comparator] with the specified configuration. * @return A new [Comparator] with the specified configuration.
@ -419,6 +445,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Utility function to create a [Comparator] a dynamic way determined by [direction] * Utility function to create a [Comparator] a dynamic way determined by [direction]
*
* @param direction The [Direction] to sort in. * @param direction The [Direction] to sort in.
* @param comparator A [Comparator] to wrap. * @param comparator A [Comparator] to wrap.
* @param selector Called to obtain a specific attribute to sort by. * @param selector Called to obtain a specific attribute to sort by.
@ -439,6 +466,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Utility function to create a [Comparator] that sorts in ascending order based on the * Utility function to create a [Comparator] that sorts in ascending order based on the
* given [Comparator], with a selector based on the item itself. * given [Comparator], with a selector based on the item itself.
*
* @param comparator The [Comparator] to wrap. * @param comparator The [Comparator] to wrap.
* @return A new [Comparator] with the specified configuration. * @return A new [Comparator] with the specified configuration.
* @see compareBy * @see compareBy
@ -448,8 +476,9 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* A [Comparator] that chains several other [Comparator]s together to form one comparison. * A [Comparator] that chains several other [Comparator]s together to form one comparison.
*
* @param comparators The [Comparator]s to chain. These will be iterated through in order * @param comparators The [Comparator]s to chain. These will be iterated through in order
* during a comparison, with the first non-equal result becoming the result. * during a comparison, with the first non-equal result becoming the result.
*/ */
private class MultiComparator<T>(vararg comparators: Comparator<T>) : Comparator<T> { private class MultiComparator<T>(vararg comparators: Comparator<T>) : Comparator<T> {
private val _comparators = comparators private val _comparators = comparators
@ -468,6 +497,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Wraps a [Comparator], extending it to compare two lists. * Wraps a [Comparator], extending it to compare two lists.
*
* @param inner The [Comparator] to use. * @param inner The [Comparator] to use.
*/ */
private class ListComparator<T>(private val inner: Comparator<T>) : Comparator<List<T>> { private class ListComparator<T>(private val inner: Comparator<T>) : Comparator<List<T>> {
@ -500,13 +530,14 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* A [Comparator] that compares abstract [Music] values. Internally, this is similar to * A [Comparator] that compares abstract [Music] values. Internally, this is similar to
* [NullableComparator], however comparing [Music.collationKey] instead of [Comparable]. * [NullableComparator], however comparing [Music.collationKey] instead of [Comparable].
*
* @see NullableComparator * @see NullableComparator
* @see Music.collationKey * @see Music.collationKey
*/ */
private class BasicComparator<T : Music> private constructor() : Comparator<T> { private class BasicComparator<T : Music> private constructor() : Comparator<T> {
override fun compare(a: T, b: T): Int { override fun compare(a: T, b: T): Int {
val aKey = a.collationKey val aKey = a.sortName
val bKey = b.collationKey val bKey = b.sortName
return when { return when {
aKey != null && bKey != null -> aKey.compareTo(bKey) aKey != null && bKey != null -> aKey.compareTo(bKey)
aKey == null && bKey != null -> -1 // a < b aKey == null && bKey != null -> -1 // a < b
@ -555,6 +586,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
companion object { companion object {
/** /**
* Convert a [Mode] integer representation into an instance. * Convert a [Mode] integer representation into an instance.
*
* @param intCode An integer representation of a [Mode] * @param intCode An integer representation of a [Mode]
* @return The corresponding [Mode], or null if the [Mode] is invalid. * @return The corresponding [Mode], or null if the [Mode] is invalid.
* @see intCode * @see intCode
@ -575,6 +607,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Convert a menu item ID into a [Mode]. * Convert a menu item ID into a [Mode].
*
* @param itemId The menu resource ID to convert * @param itemId The menu resource ID to convert
* @return A [Mode] corresponding to the given ID, or null if the ID is invalid. * @return A [Mode] corresponding to the given ID, or null if the ID is invalid.
* @see itemId * @see itemId
@ -604,6 +637,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
companion object { companion object {
/** /**
* Convert a [Sort] integer representation into an instance. * Convert a [Sort] integer representation into an instance.
*
* @param intCode An integer representation of a [Sort] * @param intCode An integer representation of a [Sort]
* @return The corresponding [Sort], or null if the [Sort] is invalid. * @return The corresponding [Sort], or null if the [Sort] is invalid.
* @see intCode * @see intCode

View file

@ -1,53 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
*
* 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.list.adapter
import androidx.recyclerview.widget.RecyclerView
/**
* A [RecyclerView.Adapter] with [ListDiffer] integration.
* @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use.
*/
abstract class DiffAdapter<T, I, VH : RecyclerView.ViewHolder>(
differFactory: ListDiffer.Factory<T, I>
) : RecyclerView.Adapter<VH>() {
private val differ = differFactory.new(@Suppress("LeakingThis") this)
final override fun getItemCount() = differ.currentList.size
/** The current list of [T] items. */
val currentList: List<T>
get() = differ.currentList
/**
* Get a [T] item at the given position.
* @param at The position to get the item at.
* @throws IndexOutOfBoundsException If the index is not in the list bounds/
*/
fun getItem(at: Int) = differ.currentList[at]
/**
* Dynamically determine how to update the list based on the given instructions.
* @param newList The new list of [T] items to show.
* @param instructions The instructions specifying how to update the list.
* @param onDone Called when the update process is completed. Defaults to a no-op.
*/
fun submitList(newList: List<T>, instructions: I, onDone: () -> Unit = {}) {
differ.submitList(newList, instructions, onDone)
}
}

View file

@ -0,0 +1,247 @@
/*
* Copyright (c) 2023 Auxio Project
* FlexibleListAdapter.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.list.adapter
import android.os.Handler
import android.os.Looper
import androidx.recyclerview.widget.*
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import java.util.concurrent.Executor
/**
* A variant of ListDiffer with more flexible updates.
*
* @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
diffCallback: DiffUtil.ItemCallback<T>
) : RecyclerView.Adapter<VH>() {
@Suppress("LeakingThis") private val differ = FlexibleListDiffer(this, diffCallback)
final override fun getItemCount() = differ.currentList.size
/** The current list stored by the adapter's differ instance. */
val currentList: List<T>
get() = differ.currentList
/** @see currentList */
fun getItem(at: Int) = differ.currentList[at]
/**
* Update the adapter with new data.
*
* @param newData The new list of data to update with.
* @param instructions The [UpdateInstructions] to visually update the list with.
* @param callback Called when the update is completed. May be done asynchronously.
*/
fun update(
newData: List<T>,
instructions: UpdateInstructions?,
callback: (() -> Unit)? = null
) = differ.update(newData, instructions, callback)
}
/**
* Arbitrary instructions that can be given to a [FlexibleListAdapter] to direct how it updates
* data.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class UpdateInstructions {
/** Use an asynchronous diff. Useful for unpredictable updates, but looks chaotic and janky. */
object Diff : UpdateInstructions()
/**
* Visually replace all items from a given point. More visually coherent than [Diff].
*
* @param from The index at which to start replacing items (inclusive)
*/
data class Replace(val from: Int) : UpdateInstructions()
/**
* Add a new set of items.
*
* @param at The position at which to add.
* @param size The amount of items to add.
*/
data class Add(val at: Int, val size: Int) : UpdateInstructions()
/**
* Move one item to another location.
*
* @param from The index of the item to move.
* @param to The index to move the item to.
*/
data class Move(val from: Int, val to: Int) : UpdateInstructions()
/**
* Remove an item.
*
* @param at The location that the item should be removed from.
*/
data class Remove(val at: Int) : UpdateInstructions()
}
/**
* Vendor of AsyncListDiffer with more flexible update functionality.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class FlexibleListDiffer<T>(
adapter: RecyclerView.Adapter<*>,
diffCallback: DiffUtil.ItemCallback<T>
) {
private val updateCallback = AdapterListUpdateCallback(adapter)
private val config = AsyncDifferConfig.Builder(diffCallback).build()
private val mainThreadExecutor = sMainThreadExecutor
private class MainThreadExecutor : Executor {
val mHandler = Handler(Looper.getMainLooper())
override fun execute(command: Runnable) {
mHandler.post(command)
}
}
var currentList = emptyList<T>()
private set
private var maxScheduledGeneration = 0
fun update(newList: List<T>, instructions: UpdateInstructions?, callback: (() -> Unit)?) {
// incrementing generation means any currently-running diffs are discarded when they finish
val runGeneration = ++maxScheduledGeneration
when (instructions) {
is UpdateInstructions.Replace -> {
updateCallback.onRemoved(instructions.from, currentList.size - instructions.from)
currentList = newList
if (newList.lastIndex >= instructions.from) {
// Need to re-insert the new data.
updateCallback.onInserted(instructions.from, newList.size - instructions.from)
}
callback?.invoke()
}
is UpdateInstructions.Add -> {
currentList = newList
updateCallback.onInserted(instructions.at, instructions.size)
callback?.invoke()
}
is UpdateInstructions.Move -> {
currentList = newList
updateCallback.onMoved(instructions.from, instructions.to)
callback?.invoke()
}
is UpdateInstructions.Remove -> {
currentList = newList
updateCallback.onRemoved(instructions.at, 1)
callback?.invoke()
}
is UpdateInstructions.Diff,
null -> diffList(currentList, newList, runGeneration, callback)
}
}
private fun diffList(
oldList: List<T>,
newList: List<T>,
runGeneration: Int,
callback: (() -> Unit)?
) {
// fast simple remove all
if (newList.isEmpty()) {
val countRemoved = oldList.size
currentList = emptyList()
// notify last, after list is updated
updateCallback.onRemoved(0, countRemoved)
callback?.invoke()
return
}
// fast simple first insert
if (oldList.isEmpty()) {
currentList = newList
// notify last, after list is updated
updateCallback.onInserted(0, newList.size)
callback?.invoke()
return
}
config.backgroundThreadExecutor.execute {
val result =
DiffUtil.calculateDiff(
object : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areItemsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
return if (oldItem != null && newItem != null) {
config.diffCallback.areItemsTheSame(oldItem, newItem)
} else oldItem == null && newItem == null
// If both items are null we consider them the same.
}
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
if (oldItem != null && newItem != null) {
return config.diffCallback.areContentsTheSame(oldItem, newItem)
}
if (oldItem == null && newItem == null) {
return true
}
throw AssertionError()
}
override fun getChangePayload(
oldItemPosition: Int,
newItemPosition: Int
): Any? {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
if (oldItem != null && newItem != null) {
return config.diffCallback.getChangePayload(oldItem, newItem)
}
throw AssertionError()
}
})
mainThreadExecutor.execute {
if (maxScheduledGeneration == runGeneration) {
currentList = newList
result.dispatchUpdatesTo(updateCallback)
callback?.invoke()
}
}
}
}
companion object {
private val sMainThreadExecutor: Executor = MainThreadExecutor()
}
}

View file

@ -1,226 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
*
* 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.list.adapter
import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
// TODO: Re-add list instructions with a less dangerous framework.
/**
* List differ wrapper that provides more flexibility regarding the way lists are updated.
* @author Alexander Capehart (OxygenCobalt)
*/
interface ListDiffer<T, I> {
/** The current list of [T] items. */
val currentList: List<T>
/**
* Dynamically determine how to update the list based on the given instructions.
* @param newList The new list of [T] items to show.
* @param instructions The [BasicListInstructions] specifying how to update the list.
* @param onDone Called when the update process is completed.
*/
fun submitList(newList: List<T>, instructions: I, onDone: () -> Unit)
/**
* Defines the creation of new [ListDiffer] instances. Allows such [ListDiffer]s to be passed as
* arguments without reliance on a `this` [RecyclerView.Adapter].
*/
abstract class Factory<T, I> {
/**
* Create a new [ListDiffer] bound to the given [RecyclerView.Adapter].
* @param adapter The [RecyclerView.Adapter] to bind to.
*/
abstract fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, I>
}
/**
* Update lists on another thread. This is useful when large diffs are likely to occur in this
* list that would be exceedingly slow with [Blocking].
* @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the
* internal list.
*/
class Async<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
Factory<T, BasicListInstructions>() {
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, BasicListInstructions> =
AsyncListDifferImpl(AdapterListUpdateCallback(adapter), diffCallback)
}
/**
* Update lists on the main thread. This is useful when many small, discrete list diffs are
* likely to occur that would cause [Async] to suffer from race conditions.
* @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the
* internal list.
*/
class Blocking<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
Factory<T, BasicListInstructions>() {
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, BasicListInstructions> =
BlockingListDifferImpl(AdapterListUpdateCallback(adapter), diffCallback)
}
}
/**
* Represents the specific way to update a list of items.
* @author Alexander Capehart (OxygenCobalt)
*/
enum class BasicListInstructions {
/**
* (A)synchronously diff the list. This should be used for small diffs with little item
* movement.
*/
DIFF,
/**
* Synchronously remove the current list and replace it with a new one. This should be used for
* large diffs with that would cause erratic scroll behavior or in-efficiency.
*/
REPLACE
}
private abstract class BasicListDiffer<T> : ListDiffer<T, BasicListInstructions> {
override fun submitList(
newList: List<T>,
instructions: BasicListInstructions,
onDone: () -> Unit
) {
when (instructions) {
BasicListInstructions.DIFF -> diffList(newList, onDone)
BasicListInstructions.REPLACE -> replaceList(newList, onDone)
}
}
protected abstract fun diffList(newList: List<T>, onDone: () -> Unit)
protected abstract fun replaceList(newList: List<T>, onDone: () -> Unit)
}
private class AsyncListDifferImpl<T>(
updateCallback: ListUpdateCallback,
diffCallback: DiffUtil.ItemCallback<T>
) : BasicListDiffer<T>() {
private val inner =
AsyncListDiffer(updateCallback, AsyncDifferConfig.Builder(diffCallback).build())
override val currentList: List<T>
get() = inner.currentList
override fun diffList(newList: List<T>, onDone: () -> Unit) {
inner.submitList(newList, onDone)
}
override fun replaceList(newList: List<T>, onDone: () -> Unit) {
inner.submitList(null) { inner.submitList(newList, onDone) }
}
}
private class BlockingListDifferImpl<T>(
private val updateCallback: ListUpdateCallback,
private val diffCallback: DiffUtil.ItemCallback<T>
) : BasicListDiffer<T>() {
override var currentList = listOf<T>()
override fun diffList(newList: List<T>, onDone: () -> Unit) {
if (newList === currentList || newList.isEmpty() && currentList.isEmpty()) {
onDone()
return
}
if (newList.isEmpty()) {
val oldListSize = currentList.size
currentList = listOf()
updateCallback.onRemoved(0, oldListSize)
onDone()
return
}
if (currentList.isEmpty()) {
currentList = newList
updateCallback.onInserted(0, newList.size)
onDone()
return
}
val oldList = currentList
val result =
DiffUtil.calculateDiff(
object : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areItemsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
return if (oldItem != null && newItem != null) {
diffCallback.areItemsTheSame(oldItem, newItem)
} else {
oldItem == null && newItem == null
}
}
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
return if (oldItem != null && newItem != null) {
diffCallback.areContentsTheSame(oldItem, newItem)
} else if (oldItem == null && newItem == null) {
true
} else {
throw AssertionError()
}
}
override fun getChangePayload(
oldItemPosition: Int,
newItemPosition: Int
): Any? {
val oldItem: T? = oldList[oldItemPosition]
val newItem: T? = newList[newItemPosition]
return if (oldItem != null && newItem != null) {
diffCallback.getChangePayload(oldItem, newItem)
} else {
throw AssertionError()
}
}
})
currentList = newList
result.dispatchUpdatesTo(updateCallback)
onDone()
}
override fun replaceList(newList: List<T>, onDone: () -> Unit) {
if (currentList != newList) {
diffList(listOf()) { diffList(newList, onDone) }
}
}
}

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* PlayingIndicatorAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -18,17 +19,19 @@
package org.oxycblt.auxio.list.adapter package org.oxycblt.auxio.list.adapter
import android.view.View import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item. * A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
* @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use. *
* @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>( abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
differFactory: ListDiffer.Factory<T, I> diffCallback: DiffUtil.ItemCallback<T>
) : DiffAdapter<T, I, VH>(differFactory) { ) : FlexibleListAdapter<T, VH>(diffCallback) {
// There are actually two states for this adapter: // There are actually two states for this adapter:
// - The currently playing item, which is usually marked as "selected" and becomes accented. // - The currently playing item, which is usually marked as "selected" and becomes accented.
// - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is // - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is
@ -39,7 +42,7 @@ abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) { override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
// Only try to update the playing indicator if the ViewHolder supports it // Only try to update the playing indicator if the ViewHolder supports it
if (holder is ViewHolder) { if (holder is ViewHolder) {
holder.updatePlayingIndicator(currentList[position] == currentItem, isPlaying) holder.updatePlayingIndicator(getItem(position) == currentItem, isPlaying)
} }
if (payloads.isEmpty()) { if (payloads.isEmpty()) {
@ -50,6 +53,7 @@ abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
} }
/** /**
* Update the currently playing item in the list. * Update the currently playing item in the list.
*
* @param item The [T] currently being played, or null if it is not being played. * @param item The [T] currently being played, or null if it is not being played.
* @param isPlaying Whether playback is ongoing or paused. * @param isPlaying Whether playback is ongoing or paused.
*/ */
@ -103,9 +107,10 @@ abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
abstract class ViewHolder(root: View) : RecyclerView.ViewHolder(root) { abstract class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
/** /**
* Update the playing indicator within this [RecyclerView.ViewHolder]. * Update the playing indicator within this [RecyclerView.ViewHolder].
*
* @param isActive True if this item is playing, false otherwise. * @param isActive True if this item is playing, false otherwise.
* @param isPlaying True if playback is ongoing, false if paused. If this is true, * @param isPlaying True if playback is ongoing, false if paused. If this is true,
* [isActive] will also be true. * [isActive] will also be true.
*/ */
abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean)
} }

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* SelectionIndicatorAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -18,18 +19,20 @@
package org.oxycblt.auxio.list.adapter package org.oxycblt.auxio.list.adapter
import android.view.View import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
/** /**
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of * A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
* items. * items.
* @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use. *
* @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class SelectionIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>( abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
differFactory: ListDiffer.Factory<T, I> diffCallback: DiffUtil.ItemCallback<T>
) : PlayingIndicatorAdapter<T, I, VH>(differFactory) { ) : PlayingIndicatorAdapter<T, VH>(diffCallback) {
private var selectedItems = setOf<T>() private var selectedItems = setOf<T>()
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) { override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
@ -41,6 +44,7 @@ abstract class SelectionIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
/** /**
* Update the list of selected items. * Update the list of selected items.
*
* @param items A set of selected [T] items. * @param items A set of selected [T] items.
*/ */
fun setSelected(items: Set<T>) { fun setSelected(items: Set<T>) {
@ -62,9 +66,7 @@ abstract class SelectionIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
} }
// Only update items that were added or removed from the list. // Only update items that were added or removed from the list.
val added = !oldSelectedItems.contains(item) && newSelectedItems.contains(item) if (oldSelectedItems.contains(item) xor newSelectedItems.contains(item)) {
val removed = oldSelectedItems.contains(item) && !newSelectedItems.contains(item)
if (added || removed) {
notifyItemChanged(i, PAYLOAD_SELECTION_INDICATOR_CHANGED) notifyItemChanged(i, PAYLOAD_SELECTION_INDICATOR_CHANGED)
} }
} }
@ -74,6 +76,7 @@ abstract class SelectionIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
abstract class ViewHolder(root: View) : PlayingIndicatorAdapter.ViewHolder(root) { abstract class ViewHolder(root: View) : PlayingIndicatorAdapter.ViewHolder(root) {
/** /**
* Update the selection indicator within this [PlayingIndicatorAdapter.ViewHolder]. * Update the selection indicator within this [PlayingIndicatorAdapter.ViewHolder].
*
* @param isSelected Whether this [PlayingIndicatorAdapter.ViewHolder] is selected. * @param isSelected Whether this [PlayingIndicatorAdapter.ViewHolder] is selected.
*/ */
abstract fun updateSelectionIndicator(isSelected: Boolean) abstract fun updateSelectionIndicator(isSelected: Boolean)

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* SimpleDiffCallback.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -23,6 +24,7 @@ import org.oxycblt.auxio.list.Item
/** /**
* A [DiffUtil.ItemCallback] that automatically implements the [areItemsTheSame] method. Use this * A [DiffUtil.ItemCallback] that automatically implements the [areItemsTheSame] method. Use this
* whenever creating [DiffUtil.ItemCallback] implementations with an [Item] subclass. * whenever creating [DiffUtil.ItemCallback] implementations with an [Item] subclass.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class SimpleDiffCallback<T : Item> : DiffUtil.ItemCallback<T>() { abstract class SimpleDiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* AuxioRecyclerView.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -31,6 +32,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Automatic edge-to-edge support * - Automatic edge-to-edge support
* - Adapter-based [SpanSizeLookup] implementation * - Adapter-based [SpanSizeLookup] implementation
* - Automatic [setHasFixedSize] setup * - Automatic [setHasFixedSize] setup
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
open class AuxioRecyclerView open class AuxioRecyclerView
@ -89,6 +91,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
interface SpanSizeLookup { interface SpanSizeLookup {
/** /**
* Get if the item at a position takes up the whole width of the [RecyclerView] or not. * Get if the item at a position takes up the whole width of the [RecyclerView] or not.
*
* @param position The position of the item. * @param position The position of the item.
* @return true if the item is full-width, false otherwise. * @return true if the item is full-width, false otherwise.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* DialogRecyclerView.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -34,6 +35,7 @@ import org.oxycblt.auxio.util.getDimenPixels
* A [RecyclerView] intended for use in Dialogs, adding features such as: * A [RecyclerView] intended for use in Dialogs, adding features such as:
* - NestedScrollView scrollIndicators behavior emulation * - NestedScrollView scrollIndicators behavior emulation
* - Dialog-specific [ViewHolder] that automatically resolves certain issues. * - Dialog-specific [ViewHolder] that automatically resolves certain issues.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class DialogRecyclerView class DialogRecyclerView

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* HeaderItemDecoration.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -19,16 +20,18 @@ package org.oxycblt.auxio.list.recycler
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.BackportMaterialDividerItemDecoration import com.google.android.material.divider.BackportMaterialDividerItemDecoration
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.adapter.DiffAdapter import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
/** /**
* A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly * A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly
* separate content with headers. * separate content with headers.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class HeaderItemDecoration class HeaderItemDecoration
@ -39,12 +42,26 @@ constructor(
defStyleAttr: Int = R.attr.materialDividerStyle, defStyleAttr: Int = R.attr.materialDividerStyle,
orientation: Int = LinearLayoutManager.VERTICAL orientation: Int = LinearLayoutManager.VERTICAL
) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) { ) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) {
override fun shouldDrawDivider(position: Int, adapter: RecyclerView.Adapter<*>?) = override fun shouldDrawDivider(position: Int, adapter: RecyclerView.Adapter<*>?): Boolean {
if (adapter is ConcatAdapter) {
val adapterAndPosition =
try {
adapter.getWrappedAdapterAndPosition(position + 1)
} catch (e: IllegalArgumentException) {
return false
}
return hasHeaderAtPosition(adapterAndPosition.second, adapterAndPosition.first)
} else {
return hasHeaderAtPosition(position + 1, adapter)
}
}
private fun hasHeaderAtPosition(position: Int, adapter: RecyclerView.Adapter<*>?) =
try { try {
// Add a divider if the next item is a header. This organizes the divider to separate // Add a divider if the next item is a header. This organizes the divider to separate
// the ends of content rather than the beginning of content, alongside an added benefit // the ends of content rather than the beginning of content, alongside an added benefit
// of preventing top headers from having a divider applied. // of preventing top headers from having a divider applied.
(adapter as DiffAdapter<*, *, *>).getItem(position + 1) is Header (adapter as FlexibleListAdapter<*, *>).getItem(position) is Header
} catch (e: ClassCastException) { } catch (e: ClassCastException) {
false false
} catch (e: IndexOutOfBoundsException) { } catch (e: IndexOutOfBoundsException) {

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* ViewHolders.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -36,12 +37,14 @@ import org.oxycblt.auxio.util.logD
/** /**
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance. * A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SongViewHolder private constructor(private val binding: ItemSongBinding) : class SongViewHolder private constructor(private val binding: ItemSongBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) { SelectionIndicatorAdapter.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param song The new [Song] to bind. * @param song The new [Song] to bind.
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
@ -67,6 +70,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
@ -84,12 +88,14 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
/** /**
* A [RecyclerView.ViewHolder] that displays a [Album]. Use [from] to create an instance. * A [RecyclerView.ViewHolder] that displays a [Album]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class AlbumViewHolder private constructor(private val binding: ItemParentBinding) : class AlbumViewHolder private constructor(private val binding: ItemParentBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) { SelectionIndicatorAdapter.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param album The new [Album] to bind. * @param album The new [Album] to bind.
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
@ -115,6 +121,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
@ -133,12 +140,14 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
/** /**
* A [RecyclerView.ViewHolder] that displays a [Artist]. Use [from] to create an instance. * A [RecyclerView.ViewHolder] that displays a [Artist]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) : class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) { SelectionIndicatorAdapter.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param artist The new [Artist] to bind. * @param artist The new [Artist] to bind.
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
@ -173,6 +182,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
@ -192,12 +202,14 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
/** /**
* A [RecyclerView.ViewHolder] that displays a [Genre]. Use [from] to create an instance. * A [RecyclerView.ViewHolder] that displays a [Genre]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class GenreViewHolder private constructor(private val binding: ItemParentBinding) : class GenreViewHolder private constructor(private val binding: ItemParentBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) { SelectionIndicatorAdapter.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param genre The new [Genre] to bind. * @param genre The new [Genre] to bind.
* @param listener An [SelectableListListener] to bind interactions to. * @param listener An [SelectableListListener] to bind interactions to.
*/ */
@ -227,6 +239,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */
@ -243,12 +256,14 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
/** /**
* A [RecyclerView.ViewHolder] that displays a [BasicHeader]. Use [from] to create an instance. * A [RecyclerView.ViewHolder] that displays a [BasicHeader]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) : class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param basicHeader The new [BasicHeader] to bind. * @param basicHeader The new [BasicHeader] to bind.
*/ */
fun bind(basicHeader: BasicHeader) { fun bind(basicHeader: BasicHeader) {
@ -262,6 +277,7 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* SelectionFragment.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -28,6 +29,7 @@ import org.oxycblt.auxio.util.showToast
/** /**
* A subset of ListFragment that implements aspects of the selection UI. * A subset of ListFragment that implements aspects of the selection UI.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class SelectionFragment<VB : ViewBinding> : abstract class SelectionFragment<VB : ViewBinding> :
@ -38,8 +40,9 @@ abstract class SelectionFragment<VB : ViewBinding> :
/** /**
* Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed by * Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed by
* [SelectionFragment]. * [SelectionFragment].
*
* @return The [SelectionToolbarOverlay] of the concrete [SelectionFragment]'s [VB], or null if * @return The [SelectionToolbarOverlay] of the concrete [SelectionFragment]'s [VB], or null if
* there is not one. * there is not one.
*/ */
open fun getSelectionToolbar(binding: VB): SelectionToolbarOverlay? = null open fun getSelectionToolbar(binding: VB): SelectionToolbarOverlay? = null

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* SelectionToolbarOverlay.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -32,6 +33,7 @@ import org.oxycblt.auxio.util.logD
/** /**
* A wrapper around a [MaterialToolbar] that adds an additional [MaterialToolbar] showing the * A wrapper around a [MaterialToolbar] that adds an additional [MaterialToolbar] showing the
* current selection state. * current selection state.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SelectionToolbarOverlay class SelectionToolbarOverlay
@ -65,6 +67,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Set an OnClickListener for when the "cancel" button in the selection [MaterialToolbar] is * Set an OnClickListener for when the "cancel" button in the selection [MaterialToolbar] is
* pressed. * pressed.
*
* @param listener The OnClickListener to respond to this interaction. * @param listener The OnClickListener to respond to this interaction.
* @see MaterialToolbar.setNavigationOnClickListener * @see MaterialToolbar.setNavigationOnClickListener
*/ */
@ -75,6 +78,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Set an [OnMenuItemClickListener] for when a MenuItem is selected from the selection * Set an [OnMenuItemClickListener] for when a MenuItem is selected from the selection
* [MaterialToolbar]. * [MaterialToolbar].
*
* @param listener The [OnMenuItemClickListener] to respond to this interaction. * @param listener The [OnMenuItemClickListener] to respond to this interaction.
* @see MaterialToolbar.setOnMenuItemClickListener * @see MaterialToolbar.setOnMenuItemClickListener
*/ */
@ -84,6 +88,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Update the selection [MaterialToolbar] to reflect the current selection amount. * Update the selection [MaterialToolbar] to reflect the current selection amount.
*
* @param amount The amount of items that are currently selected. * @param amount The amount of items that are currently selected.
* @return true if the selection [MaterialToolbar] changes, false otherwise. * @return true if the selection [MaterialToolbar] changes, false otherwise.
*/ */
@ -101,6 +106,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Animate the visibility of the inner and selection [MaterialToolbar]s to the given state. * Animate the visibility of the inner and selection [MaterialToolbar]s to the given state.
*
* @param selectionVisible Whether the selection [MaterialToolbar] should be visible or not. * @param selectionVisible Whether the selection [MaterialToolbar] should be visible or not.
* @return true if the toolbars have changed, false otherwise. * @return true if the toolbars have changed, false otherwise.
*/ */
@ -152,8 +158,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/** /**
* Update the alpha of the inner and selection [MaterialToolbar]s. * Update the alpha of the inner and selection [MaterialToolbar]s.
*
* @param innerAlpha The opacity of the inner [MaterialToolbar]. This will map to the inverse * @param innerAlpha The opacity of the inner [MaterialToolbar]. This will map to the inverse
* opacity of the selection [MaterialToolbar]. * opacity of the selection [MaterialToolbar].
*/ */
private fun setToolbarsAlpha(innerAlpha: Float) { private fun setToolbarsAlpha(innerAlpha: Float) {
innerToolbar.apply { innerToolbar.apply {

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* SelectionViewModel.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -27,6 +28,7 @@ import org.oxycblt.auxio.music.model.Library
/** /**
* A [ViewModel] that manages the current selection. * A [ViewModel] that manages the current selection.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@HiltViewModel @HiltViewModel
@ -67,6 +69,7 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR
/** /**
* Select a new [Music] item. If this item is already within the selected items, the item will * Select a new [Music] item. If this item is already within the selected items, the item will
* be removed. Otherwise, it will be added. * be removed. Otherwise, it will be added.
*
* @param music The [Music] item to select. * @param music The [Music] item to select.
*/ */
fun select(music: Music) { fun select(music: Music) {
@ -79,6 +82,7 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR
/** /**
* Consume the current selection. This will clear any items that were selected prior. * Consume the current selection. This will clear any items that were selected prior.
*
* @return The list of selected items before it was cleared. * @return The list of selected items before it was cleared.
*/ */
fun consume() = _selected.value.also { _selected.value = listOf() } fun consume() = _selected.value.also { _selected.value = listOf() }

View file

@ -1,44 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
*
* 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 com.google.android.exoplayer2.extractor.ExtractorsFactory
import com.google.android.exoplayer2.extractor.flac.FlacExtractor
import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor
import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor
import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor
import com.google.android.exoplayer2.extractor.ogg.OggExtractor
import com.google.android.exoplayer2.extractor.ts.AdtsExtractor
import com.google.android.exoplayer2.extractor.wav.WavExtractor
/**
* A [ExtractorsFactory] that only provides audio containers to save APK space.
* @author Alexander Capehart (OxygenCobalt)
*/
object AudioOnlyExtractors : ExtractorsFactory {
override fun createExtractors() =
arrayOf(
FlacExtractor(),
WavExtractor(),
Mp4Extractor(),
OggExtractor(),
MatroskaExtractor(),
// Enable constant bitrate seeking so that certain MP3s/AACs are seekable
AdtsExtractor(AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING),
Mp3Extractor(Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING))
}

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* Music.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -22,6 +23,7 @@ import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import java.security.MessageDigest import java.security.MessageDigest
import java.text.CollationKey import java.text.CollationKey
import java.text.Collator
import java.util.UUID import java.util.UUID
import kotlin.math.max import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
@ -38,11 +40,13 @@ import org.oxycblt.auxio.util.toUuidOrNull
/** /**
* Abstract music data. This contains universal information about all concrete music * Abstract music data. This contains universal information about all concrete music
* implementations, such as identification information and names. * implementations, such as identification information and names.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
sealed interface Music : Item { sealed interface Music : Item {
/** /**
* A unique identifier for this music item. * A unique identifier for this music item.
*
* @see UID * @see UID
*/ */
val uid: UID val uid: UID
@ -56,9 +60,10 @@ sealed interface Music : Item {
/** /**
* Returns a name suitable for use in the app UI. This should be favored over [rawName] in * Returns a name suitable for use in the app UI. This should be favored over [rawName] in
* nearly all cases. * nearly all cases.
*
* @param context [Context] required to obtain placeholder text or formatting information. * @param context [Context] required to obtain placeholder text or formatting information.
* @return A human-readable string representing the name of this music. In the case that the * @return A human-readable string representing the name of this music. In the case that the
* item does not have a name, an analogous "Unknown X" name is returned. * item does not have a name, an analogous "Unknown X" name is returned.
*/ */
fun resolveName(context: Context): String fun resolveName(context: Context): String
@ -70,31 +75,26 @@ sealed interface Music : Item {
val rawSortName: String? val rawSortName: String?
/** /**
* A [CollationKey] derived from [rawName] and [rawSortName] that can be used to sort items in a * A black-box value derived from [rawSortName] and [rawName] that can be used for user-friendly
* semantically-correct manner. Will be null if the item has no name. * sorting in the context of music. This should be preferred over [rawSortName] in most cases.
* * Null if there are no [rawName] or [rawSortName] values to build on.
* The key will have the following attributes:
* - If [rawSortName] is present, this key will be derived from it. Otherwise [rawName] is used.
* - If the string begins with an article, such as "the", it will be stripped, as is usually
* convention for sorting media. This is not internationalized.
*/ */
val collationKey: CollationKey? val sortName: SortName?
/** /**
* A unique identifier for a piece of music. * A unique identifier for a piece of music.
* *
* [UID] enables a much cheaper and more reliable form of differentiating music, derived from * [UID] enables a much cheaper and more reliable form of differentiating music, derived from
* either a hash of meaningful metadata or the MusicBrainz ID spec. Using this enables several * either internal app information or the MusicBrainz ID spec. Using this enables several
* improvements to music management in this app, including: * improvements to music management in this app, including:
*
* - Proper differentiation of identical music. It's common for large, well-tagged libraries to * - Proper differentiation of identical music. It's common for large, well-tagged libraries to
* have functionally duplicate items that are differentiated with MusicBrainz IDs, and so [UID] * have functionally duplicate items that are differentiated with MusicBrainz IDs, and so
* allows us to properly differentiate between these in the app. * [UID] allows us to properly differentiate between these in the app.
* - Better music persistence between restarts. Whereas directly storing song names would be * - Better music persistence between restarts. Whereas directly storing song names would be
* prone to collisions, and storing MediaStore IDs would drift rapidly as the music library * prone to collisions, and storing MediaStore IDs would drift rapidly as the music library
* changes, [UID] enables a much stronger form of persistence given it's unique link to a * changes, [UID] enables a much stronger form of persistence given it's unique link to a
* specific files metadata configuration, which is unlikely to collide with another item or * specific files metadata configuration, which is unlikely to collide with another item or
* drift as the music library changes. * drift as the music library changes.
* *
* Note: Generally try to use [UID] as a black box that can only be read, written, and compared. * Note: Generally try to use [UID] as a black box that can only be read, written, and compared.
* It will not be fun if you try to manipulate it in any other manner. * It will not be fun if you try to manipulate it in any other manner.
@ -125,6 +125,7 @@ sealed interface Music : Item {
/** /**
* Internal marker of [Music.UID] format type. * Internal marker of [Music.UID] format type.
*
* @param namespace Namespace to use in the [Music.UID]'s string representation. * @param namespace Namespace to use in the [Music.UID]'s string representation.
*/ */
private enum class Format(val namespace: String) { private enum class Format(val namespace: String) {
@ -139,10 +140,11 @@ sealed interface Music : Item {
/** /**
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective, * Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
* unlikely-to-change metadata of the music. * unlikely-to-change metadata of the music.
*
* @param mode The analogous [MusicMode] of the item that created this [UID]. * @param mode The analogous [MusicMode] of the item that created this [UID].
* @param updates Block to update the [MessageDigest] hash with the metadata of the * @param updates Block to update the [MessageDigest] hash with the metadata of the
* item. Make sure the metadata hashed semantically aligns with the format * item. Make sure the metadata hashed semantically aligns with the format
* specification. * specification.
* @return A new auxio-style [UID]. * @return A new auxio-style [UID].
*/ */
fun auxio(mode: MusicMode, updates: MessageDigest.() -> Unit): UID { fun auxio(mode: MusicMode, updates: MessageDigest.() -> Unit): UID {
@ -181,19 +183,21 @@ sealed interface Music : Item {
/** /**
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID * Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
* extracted from a file. * extracted from a file.
*
* @param mode The analogous [MusicMode] of the item that created this [UID]. * @param mode The analogous [MusicMode] of the item that created this [UID].
* @param mbid The analogous MusicBrainz ID for this item that was extracted from a * @param mbid The analogous MusicBrainz ID for this item that was extracted from a
* file. * file.
* @return A new MusicBrainz-style [UID]. * @return A new MusicBrainz-style [UID].
*/ */
fun musicBrainz(mode: MusicMode, mbid: UUID): UID = UID(Format.MUSICBRAINZ, mode, mbid) fun musicBrainz(mode: MusicMode, mbid: UUID): UID = UID(Format.MUSICBRAINZ, mode, mbid)
/** /**
* Convert a [UID]'s string representation back into a concrete [UID] instance. * Convert a [UID]'s string representation back into a concrete [UID] instance.
*
* @param uid The [UID]'s string representation, formatted as * @param uid The [UID]'s string representation, formatted as
* `format_namespace:music_mode_int-uuid`. * `format_namespace:music_mode_int-uuid`.
* @return A [UID] converted from the string representation, or null if the string * @return A [UID] converted from the string representation, or null if the string
* representation was invalid. * representation was invalid.
*/ */
fun fromString(uid: String): UID? { fun fromString(uid: String): UID? {
val split = uid.split(':', limit = 2) val split = uid.split(':', limit = 2)
@ -224,6 +228,7 @@ sealed interface Music : Item {
/** /**
* An abstract grouping of [Song]s and other [Music] data. * An abstract grouping of [Song]s and other [Music] data.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
sealed interface MusicParent : Music { sealed interface MusicParent : Music {
@ -233,6 +238,7 @@ sealed interface MusicParent : Music {
/** /**
* A song. * A song.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Song : Music { interface Song : Music {
@ -281,6 +287,7 @@ interface Song : Music {
/** /**
* An abstract release group. While it may be called an album, it encompasses other types of * An abstract release group. While it may be called an album, it encompasses other types of
* releases like singles, EPs, and compilations. * releases like singles, EPs, and compilations.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Album : MusicParent { interface Album : MusicParent {
@ -311,6 +318,7 @@ interface Album : MusicParent {
/** /**
* An abstract artist. These are actually a combination of the artist and album artist tags from * An abstract artist. These are actually a combination of the artist and album artist tags from
* within the library, derived from [Song]s and [Album]s respectively. * within the library, derived from [Song]s and [Album]s respectively.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Artist : MusicParent { interface Artist : MusicParent {
@ -336,6 +344,7 @@ interface Artist : MusicParent {
/** /**
* A genre. * A genre.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Genre : MusicParent { interface Genre : MusicParent {
@ -347,9 +356,84 @@ interface Genre : MusicParent {
val durationMs: Long val durationMs: Long
} }
/**
* A black-box datatype for a variation of music names that is suitable for music-oriented sorting.
* It will automatically handle articles like "The" and numeric components like "An".
*
* @author Alexander Capehart (OxygenCobalt)
*/
class SortName(name: String, musicSettings: MusicSettings) : Comparable<SortName> {
private val number: Int?
private val collationKey: CollationKey
val thumbString: String?
init {
var sortName = name
if (musicSettings.intelligentSorting) {
sortName =
sortName.run {
when {
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
else -> this
}
}
// Parse out numeric portions of the title and use those for sorting, if applicable.
when (val numericEnd = sortName.indexOfFirst { !it.isDigit() }) {
// No numeric component.
0 -> number = null
// Whole title is numeric.
-1 -> {
number = sortName.toIntOrNull()
sortName = ""
}
// Part of the title is numeric.
else -> {
number = sortName.slice(0 until numericEnd).toIntOrNull()
sortName = sortName.slice(numericEnd until sortName.length)
}
}
} else {
number = null
}
collationKey = COLLATOR.getCollationKey(sortName)
// Keep track of a string to use in the thumb view.
// TODO: This needs to be moved elsewhere.
thumbString = (number?.toString() ?: collationKey?.run { sourceString.first().uppercase() })
}
override fun toString(): String = number?.toString() ?: collationKey.sourceString
override fun compareTo(other: SortName) =
when {
number != null && other.number != null -> number.compareTo(other.number)
number != null && other.number == null -> -1 // a < b
number == null && other.number != null -> 1 // a > b
else -> collationKey.compareTo(other.collationKey)
}
override fun equals(other: Any?) =
other is SortName && number == other.number && collationKey == other.collationKey
override fun hashCode(): Int {
var hashCode = collationKey.hashCode()
if (number != null) hashCode = 31 * hashCode + number
return hashCode
}
private companion object {
val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
}
}
/** /**
* Run [Music.resolveName] on each instance in the given list and concatenate them into a [String] * Run [Music.resolveName] on each instance in the given list and concatenate them into a [String]
* in a localized manner. * in a localized manner.
*
* @param context [Context] required * @param context [Context] required
* @return A concatenated string. * @return A concatenated string.
*/ */
@ -359,6 +443,7 @@ fun <T : Music> List<T>.resolveNames(context: Context) =
/** /**
* Returns if [Music.rawName] matches for each item in a list. Useful for scenarios where the * Returns if [Music.rawName] matches for each item in a list. Useful for scenarios where the
* display information of an item must be compared without a context. * display information of an item must be compared without a context.
*
* @param other The list of items to compare to. * @param other The list of items to compare to.
* @return True if they are the same (by [Music.rawName]), false otherwise. * @return True if they are the same (by [Music.rawName]), false otherwise.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* MusicMode.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -21,6 +22,7 @@ import org.oxycblt.auxio.IntegerTable
/** /**
* Represents a data configuration corresponding to a specific type of [Music], * Represents a data configuration corresponding to a specific type of [Music],
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
enum class MusicMode { enum class MusicMode {
@ -35,6 +37,7 @@ enum class MusicMode {
/** /**
* The integer representation of this instance. * The integer representation of this instance.
*
* @see fromIntCode * @see fromIntCode
*/ */
val intCode: Int val intCode: Int
@ -49,6 +52,7 @@ enum class MusicMode {
companion object { companion object {
/** /**
* Convert a [MusicMode] integer representation into an instance. * Convert a [MusicMode] integer representation into an instance.
*
* @param intCode An integer representation of a [MusicMode] * @param intCode An integer representation of a [MusicMode]
* @return The corresponding [MusicMode], or null if the [MusicMode] is invalid. * @return The corresponding [MusicMode], or null if the [MusicMode] is invalid.
* @see MusicMode.intCode * @see MusicMode.intCode

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* MusicModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* MusicRepository.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -40,6 +41,7 @@ interface MusicRepository {
/** /**
* Add a [Listener] to this instance. This can be used to receive changes in the music library. * Add a [Listener] to this instance. This can be used to receive changes in the music library.
* Will invoke all [Listener] methods to initialize the instance with the current state. * Will invoke all [Listener] methods to initialize the instance with the current state.
*
* @param listener The [Listener] to add. * @param listener The [Listener] to add.
* @see Listener * @see Listener
*/ */
@ -47,8 +49,9 @@ interface MusicRepository {
/** /**
* Remove a [Listener] from this instance, preventing it from receiving any further updates. * Remove a [Listener] from this instance, preventing it from receiving any further updates.
*
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
* the first place. * the first place.
* @see Listener * @see Listener
*/ */
fun removeListener(listener: Listener) fun removeListener(listener: Listener)
@ -57,6 +60,7 @@ interface MusicRepository {
interface Listener { interface Listener {
/** /**
* Called when the current [Library] has changed. * Called when the current [Library] has changed.
*
* @param library The new [Library], or null if no [Library] has been loaded yet. * @param library The new [Library], or null if no [Library] has been loaded yet.
*/ */
fun onLibraryChanged(library: Library?) fun onLibraryChanged(library: Library?)

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* MusicSettings.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -31,6 +32,7 @@ import org.oxycblt.auxio.util.getSystemServiceCompat
/** /**
* User configuration specific to music system. * User configuration specific to music system.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface MusicSettings : Settings<MusicSettings.Listener> { interface MusicSettings : Settings<MusicSettings.Listener> {
@ -42,8 +44,9 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
val shouldBeObserving: Boolean val shouldBeObserving: Boolean
/** A [String] of characters representing the desired characters to denote multi-value tags. */ /** A [String] of characters representing the desired characters to denote multi-value tags. */
var multiValueSeparators: String var multiValueSeparators: String
/** Whether to trim english articles with song sort names. */ /** Whether to enable more advanced sorting by articles and numbers. */
val automaticSortNames: Boolean val intelligentSorting: Boolean
// TODO: Move sort settings to list module
/** The [Sort] mode used in [Song] lists. */ /** The [Sort] mode used in [Song] lists. */
var songSort: Sort var songSort: Sort
/** The [Sort] mode used in [Album] lists. */ /** The [Sort] mode used in [Album] lists. */
@ -108,7 +111,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
} }
} }
override val automaticSortNames: Boolean override val intelligentSorting: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_auto_sort_names), true) get() = sharedPreferences.getBoolean(getString(R.string.set_key_auto_sort_names), true)
override var songSort: Sort override var songSort: Sort

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* MusicViewModel.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -26,6 +27,7 @@ import org.oxycblt.auxio.music.system.Indexer
/** /**
* A [ViewModel] providing data specific to the music loading process. * A [ViewModel] providing data specific to the music loading process.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@HiltViewModel @HiltViewModel
@ -76,6 +78,7 @@ class MusicViewModel @Inject constructor(private val indexer: Indexer) :
/** /**
* Non-manipulated statistics bound the last successful music load. * Non-manipulated statistics bound the last successful music load.
*
* @param songs The amount of [Song]s that were loaded. * @param songs The amount of [Song]s that were loaded.
* @param albums The amount of [Album]s that were created. * @param albums The amount of [Album]s that were created.
* @param artists The amount of [Artist]s that were created. * @param artists The amount of [Artist]s that were created.

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* CacheDatabase.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* CacheModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* CacheRepository.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -23,17 +24,20 @@ import org.oxycblt.auxio.util.*
/** /**
* A repository allowing access to cached metadata obtained in prior music loading operations. * A repository allowing access to cached metadata obtained in prior music loading operations.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface CacheRepository { interface CacheRepository {
/** /**
* Read the current [Cache], if it exists. * Read the current [Cache], if it exists.
*
* @return The stored [Cache], or null if it could not be obtained. * @return The stored [Cache], or null if it could not be obtained.
*/ */
suspend fun readCache(): Cache? suspend fun readCache(): Cache?
/** /**
* Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data. * Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data.
*
* @param rawSongs The [rawSongs] to write to the cache. * @param rawSongs The [rawSongs] to write to the cache.
*/ */
suspend fun writeCache(rawSongs: List<RawSong>) suspend fun writeCache(rawSongs: List<RawSong>)
@ -67,6 +71,7 @@ class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: Cached
/** /**
* A cache of music metadata obtained in prior music loading operations. Obtain an instance with * A cache of music metadata obtained in prior music loading operations. Obtain an instance with
* [CacheRepository]. * [CacheRepository].
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Cache { interface Cache {
@ -75,6 +80,7 @@ interface Cache {
/** /**
* Populate a [RawSong] from a cache entry, if it exists. * Populate a [RawSong] from a cache entry, if it exists.
*
* @param rawSong The [RawSong] to populate. * @param rawSong The [RawSong] to populate.
* @return true if a cache entry could be applied to [rawSong], false otherwise. * @return true if a cache entry could be applied to [rawSong], false otherwise.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* AudioInfo.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -30,6 +31,7 @@ import org.oxycblt.auxio.util.logW
/** /**
* The properties of a [Song]'s file. * The properties of a [Song]'s file.
*
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed. * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
* @param sampleRateHz The sample rate, in hertz. * @param sampleRateHz The sample rate, in hertz.
* @param resolvedMimeType The known mime type of the [Song] after it's file format was determined. * @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
@ -44,6 +46,7 @@ data class AudioInfo(
interface Provider { interface Provider {
/** /**
* Extract the [AudioInfo] of a given [Song]. * Extract the [AudioInfo] of a given [Song].
*
* @param song The [Song] to read. * @param song The [Song] to read.
* @return The [AudioInfo] of the [Song], if possible to obtain. * @return The [AudioInfo] of the [Song], if possible to obtain.
*/ */
@ -53,6 +56,7 @@ data class AudioInfo(
/** /**
* A framework-backed implementation of [AudioInfo.Provider]. * A framework-backed implementation of [AudioInfo.Provider].
*
* @param context [Context] required to read audio files. * @param context [Context] required to read audio files.
*/ */
class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) : class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) :

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* Date.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -44,10 +45,11 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/** /**
* Resolve this instance into a human-readable date. * Resolve this instance into a human-readable date.
*
* @param context [Context] required to get human-readable names. * @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 * @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 * 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
* be properly localized. * be properly localized.
*/ */
fun resolveDate(context: Context): String { fun resolveDate(context: Context): String {
if (month != null) { if (month != null) {
@ -115,6 +117,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
* A range of [Date]s. This is used in contexts where the [Date] of an item is derived from * A range of [Date]s. This is used in contexts where the [Date] of an item is derived from
* several sub-items and thus can have a "range" of release dates. Use [from] to create an * several sub-items and thus can have a "range" of release dates. Use [from] to create an
* instance. * instance.
*
* @author Alexander Capehart * @author Alexander Capehart
*/ */
class Range class Range
@ -127,10 +130,11 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/** /**
* Resolve this instance into a human-readable date range. * Resolve this instance into a human-readable date range.
*
* @param context [Context] required to get human-readable names. * @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 * @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. * returned with the formatted [Date]s of the minimum and maximum dates respectively.
* Otherwise, the formatted name of the minimum [Date] will be returned. * Otherwise, the formatted name of the minimum [Date] will be returned.
*/ */
fun resolveDate(context: Context) = fun resolveDate(context: Context) =
if (min != max) { if (min != max) {
@ -149,9 +153,10 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
companion object { companion object {
/** /**
* Create a [Range] from the given list of [Date]s. * Create a [Range] from the given list of [Date]s.
*
* @param dates The [Date]s to use. * @param dates The [Date]s to use.
* @return A [Range] based on the minimum and maximum [Date]s. If there are no [Date]s, * @return A [Range] based on the minimum and maximum [Date]s. If there are no [Date]s,
* null is returned. * null is returned.
*/ */
fun from(dates: List<Date>): Range? { fun from(dates: List<Date>): Range? {
if (dates.isEmpty()) { if (dates.isEmpty()) {
@ -186,6 +191,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/** /**
* Create a [Date] from a year component. * Create a [Date] from a year component.
*
* @param year The year component. * @param year The year component.
* @return A new [Date] of the given component, or null if the component is invalid. * @return A new [Date] of the given component, or null if the component is invalid.
*/ */
@ -204,38 +210,41 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/** /**
* Create a [Date] from a date component. * Create a [Date] from a date component.
*
* @param year The year component. * @param year The year component.
* @param month The month component. * @param month The month component.
* @param day The day component. * @param day The day component.
* @return A new [Date] consisting of the given components. May have reduced precision if * @return A new [Date] consisting of the given components. May have reduced precision if
* the components were partially invalid, and will be null if all components are invalid. * the components were partially invalid, and will be null if all components are invalid.
*/ */
fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day)) fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day))
/** /**
* Create [Date] from a datetime component. * Create [Date] from a datetime component.
*
* @param year The year component. * @param year The year component.
* @param month The month component. * @param month The month component.
* @param day The day component. * @param day The day component.
* @param hour The hour component * @param hour The hour component
* @return A new [Date] consisting of the given components. May have reduced precision if * @return A new [Date] consisting of the given components. May have reduced precision if
* the components were partially invalid, and will be null if all components are invalid. * the components were partially invalid, and will be null if all components are invalid.
*/ */
fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) = fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) =
fromTokens(listOf(year, month, day, hour, minute)) fromTokens(listOf(year, month, day, hour, minute))
/** /**
* Create a [Date] from a [String] timestamp. * Create a [Date] from a [String] timestamp.
*
* @param timestamp The ISO-8601 timestamp to parse. Can have reduced precision. * @param timestamp The ISO-8601 timestamp to parse. Can have reduced precision.
* @return A new [Date] consisting of the given components. May have reduced precision if * @return A new [Date] consisting of the given components. May have reduced precision if
* the components were partially invalid, and will be null if all components are invalid or * the components were partially invalid, and will be null if all components are invalid
* if the timestamp is invalid. * or if the timestamp is invalid.
*/ */
fun from(timestamp: String): Date? { fun from(timestamp: String): Date? {
val tokens = val tokens =
// Match the input with the timestamp regex. If there is no match, see if we can // Match the input with the timestamp regex. If there is no match, see if we can
// fall back to some kind of year value. // fall back to some kind of year value.
(ISO8601_REGEX.matchEntire(timestamp) (ISO8601_REGEX.matchEntire(timestamp)
?: return timestamp.toIntOrNull()?.let(Companion::from)) ?: return timestamp.toIntOrNull()?.let(Companion::from))
.groupValues .groupValues
// Filter to the specific tokens we want and convert them to integer tokens. // Filter to the specific tokens we want and convert them to integer tokens.
@ -245,9 +254,10 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/** /**
* Create a [Date] from the given non-validated tokens. * Create a [Date] from the given non-validated tokens.
*
* @param tokens The tokens to use for each date component, in order of precision. * @param tokens The tokens to use for each date component, in order of precision.
* @return A new [Date] consisting of the given components. May have reduced precision if * @return A new [Date] consisting of the given components. May have reduced precision if
* the components were partially invalid, and will be null if all components are invalid. * the components were partially invalid, and will be null if all components are invalid.
*/ */
private fun fromTokens(tokens: List<Int>): Date? { private fun fromTokens(tokens: List<Int>): Date? {
val validated = mutableListOf<Int>() val validated = mutableListOf<Int>()
@ -262,6 +272,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/** /**
* Validate a list of tokens provided by [src], and add the valid ones to [dst]. Will stop * Validate a list of tokens provided by [src], and add the valid ones to [dst]. Will stop
* as soon as an invalid token is found. * as soon as an invalid token is found.
*
* @param src The input tokens to validate. * @param src The input tokens to validate.
* @param dst The destination list to add valid tokens to. * @param dst The destination list to add valid tokens to.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* Disc.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -21,6 +22,7 @@ import org.oxycblt.auxio.list.Item
/** /**
* A disc identifier for a song. * A disc identifier for a song.
*
* @param number The disc number. * @param number The disc number.
* @param name The name of the disc group, if any. Null if not present. * @param name The name of the disc group, if any. Null if not present.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* MetadataModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -26,5 +27,6 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface MetadataModule { interface MetadataModule {
@Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor @Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor
@Binds fun tagWorkerFactory(taskFactory: TagWorkerImpl.Factory): TagWorker.Factory
@Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider @Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider
} }

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* ReleaseType.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -24,6 +25,7 @@ import org.oxycblt.auxio.R
* *
* This class is derived from the MusicBrainz Release Group Type specification. It can be found at: * This class is derived from the MusicBrainz Release Group Type specification. It can be found at:
* https://musicbrainz.org/doc/Release_Group/Type * https://musicbrainz.org/doc/Release_Group/Type
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
sealed class ReleaseType { sealed class ReleaseType {
@ -38,8 +40,9 @@ sealed class ReleaseType {
/** /**
* A plain album. * A plain album.
*
* @param refinement A specification of what kind of performance this release is. If null, the * @param refinement A specification of what kind of performance this release is. If null, the
* release is considered "Plain". * release is considered "Plain".
*/ */
data class Album(override val refinement: Refinement?) : ReleaseType() { data class Album(override val refinement: Refinement?) : ReleaseType() {
override val stringRes: Int override val stringRes: Int
@ -54,8 +57,9 @@ sealed class ReleaseType {
/** /**
* A "Extended Play", or EP. Usually a smaller release consisting of 4-5 songs. * A "Extended Play", or EP. Usually a smaller release consisting of 4-5 songs.
*
* @param refinement A specification of what kind of performance this release is. If null, the * @param refinement A specification of what kind of performance this release is. If null, the
* release is considered "Plain". * release is considered "Plain".
*/ */
data class EP(override val refinement: Refinement?) : ReleaseType() { data class EP(override val refinement: Refinement?) : ReleaseType() {
override val stringRes: Int override val stringRes: Int
@ -70,8 +74,9 @@ sealed class ReleaseType {
/** /**
* A single. Usually a release consisting of 1-2 songs. * A single. Usually a release consisting of 1-2 songs.
*
* @param refinement A specification of what kind of performance this release is. If null, the * @param refinement A specification of what kind of performance this release is. If null, the
* release is considered "Plain". * release is considered "Plain".
*/ */
data class Single(override val refinement: Refinement?) : ReleaseType() { data class Single(override val refinement: Refinement?) : ReleaseType() {
override val stringRes: Int override val stringRes: Int
@ -86,8 +91,9 @@ sealed class ReleaseType {
/** /**
* A compilation. Usually consists of many songs from a variety of artists. * A compilation. Usually consists of many songs from a variety of artists.
*
* @param refinement A specification of what kind of performance this release is. If null, the * @param refinement A specification of what kind of performance this release is. If null, the
* release is considered "Plain". * release is considered "Plain".
*/ */
data class Compilation(override val refinement: Refinement?) : ReleaseType() { data class Compilation(override val refinement: Refinement?) : ReleaseType() {
override val stringRes: Int override val stringRes: Int
@ -149,9 +155,10 @@ sealed class ReleaseType {
/** /**
* Parse a [ReleaseType] from a string formatted with the MusicBrainz Release Group Type * Parse a [ReleaseType] from a string formatted with the MusicBrainz Release Group Type
* specification. * specification.
*
* @param types A list of values consisting of valid release type values. * @param types A list of values consisting of valid release type values.
* @return A [ReleaseType] consisting of the given types, or null if the types were not * @return A [ReleaseType] consisting of the given types, or null if the types were not
* valid. * valid.
*/ */
fun parse(types: List<String>): ReleaseType? { fun parse(types: List<String>): ReleaseType? {
val primary = types.getOrNull(0) ?: return null val primary = types.getOrNull(0) ?: return null
@ -170,10 +177,11 @@ sealed class ReleaseType {
/** /**
* Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted with * Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted with
* the MusicBrainz Release Group Type specification. * the MusicBrainz Release Group Type specification.
*
* @param index The index of the release type to parse. * @param index The index of the release type to parse.
* @param convertRefinement Code to convert a [Refinement] into a [ReleaseType] * @param convertRefinement Code to convert a [Refinement] into a [ReleaseType]
* corresponding to the callee's context. This is used in order to handle secondary times * corresponding to the callee's context. This is used in order to handle secondary times
* that are actually [Refinement]s. * that are actually [Refinement]s.
* @return A [ReleaseType] corresponding to the secondary type found at that index. * @return A [ReleaseType] corresponding to the secondary type found at that index.
*/ */
private inline fun List<String>.parseSecondaryTypes( private inline fun List<String>.parseSecondaryTypes(
@ -194,10 +202,11 @@ sealed class ReleaseType {
/** /**
* Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to any * Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to any
* child values. * child values.
*
* @param type The release type value to parse. * @param type The release type value to parse.
* @param convertRefinement Code to convert a [Refinement] into a [ReleaseType] * @param convertRefinement Code to convert a [Refinement] into a [ReleaseType]
* corresponding to the callee's context. This is used in order to handle secondary times * corresponding to the callee's context. This is used in order to handle secondary times
* that are actually [Refinement]s. * that are actually [Refinement]s.
*/ */
private inline fun parseSecondaryTypeImpl( private inline fun parseSecondaryTypeImpl(
type: String?, type: String?,

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* Separators.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -19,6 +20,7 @@ package org.oxycblt.auxio.music.metadata
/** /**
* Defines the allowed separator characters that can be used to delimit multi-value tags. * Defines the allowed separator characters that can be used to delimit multi-value tags.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
object Separators { object Separators {

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* SeparatorsDialog.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -33,6 +34,7 @@ import org.oxycblt.auxio.ui.ViewBindingDialogFragment
/** /**
* A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to * A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to
* split tags with multiple values. * split tags with multiple values.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @AndroidEntryPoint

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* TagExtractor.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -17,20 +18,11 @@
package org.oxycblt.auxio.music.metadata package org.oxycblt.auxio.music.metadata
import android.content.Context
import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.AudioOnlyExtractors
import org.oxycblt.auxio.music.model.RawSong import org.oxycblt.auxio.music.model.RawSong
import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/** /**
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the * The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
@ -43,13 +35,14 @@ interface TagExtractor {
/** /**
* Extract the metadata of songs from [incompleteSongs] and send them to [completeSongs]. Will * Extract the metadata of songs from [incompleteSongs] and send them to [completeSongs]. Will
* terminate as soon as [incompleteSongs] is closed. * terminate as soon as [incompleteSongs] is closed.
*
* @param incompleteSongs A [Channel] of incomplete songs to process. * @param incompleteSongs A [Channel] of incomplete songs to process.
* @param completeSongs A [Channel] to send completed songs to. * @param completeSongs A [Channel] to send completed songs to.
*/ */
suspend fun consume(incompleteSongs: Channel<RawSong>, completeSongs: Channel<RawSong>) suspend fun consume(incompleteSongs: Channel<RawSong>, completeSongs: Channel<RawSong>)
} }
class TagExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) : class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWorker.Factory) :
TagExtractor { TagExtractor {
override suspend fun consume( override suspend fun consume(
incompleteSongs: Channel<RawSong>, incompleteSongs: Channel<RawSong>,
@ -57,22 +50,22 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte
) { ) {
// We can parallelize MetadataRetriever Futures to work around it's speed issues, // We can parallelize MetadataRetriever Futures to work around it's speed issues,
// producing similar throughput's to other kinds of manual metadata extraction. // producing similar throughput's to other kinds of manual metadata extraction.
val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY) val tagWorkerPool: Array<TagWorker?> = arrayOfNulls(TASK_CAPACITY)
for (song in incompleteSongs) { for (incompleteRawSong in incompleteSongs) {
spin@ while (true) { spin@ while (true) {
for (i in taskPool.indices) { for (i in tagWorkerPool.indices) {
val task = taskPool[i] val worker = tagWorkerPool[i]
if (task != null) { if (worker != null) {
val finishedRawSong = task.get() val completeRawSong = worker.poll()
if (finishedRawSong != null) { if (completeRawSong != null) {
completeSongs.send(finishedRawSong) completeSongs.send(completeRawSong)
yield() yield()
} else { } else {
continue continue
} }
} }
taskPool[i] = Task(context, song) tagWorkerPool[i] = tagWorkerFactory.create(incompleteRawSong)
break@spin break@spin
} }
} }
@ -80,13 +73,13 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte
do { do {
var ongoingTasks = false var ongoingTasks = false
for (i in taskPool.indices) { for (i in tagWorkerPool.indices) {
val task = taskPool[i] val task = tagWorkerPool[i]
if (task != null) { if (task != null) {
val finishedRawSong = task.get() val completeRawSong = task.poll()
if (finishedRawSong != null) { if (completeRawSong != null) {
completeSongs.send(finishedRawSong) completeSongs.send(completeRawSong)
taskPool[i] = null tagWorkerPool[i] = null
yield() yield()
} else { } else {
ongoingTasks = true ongoingTasks = true
@ -102,216 +95,3 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte
const val TASK_CAPACITY = 8 const val TASK_CAPACITY = 8
} }
} }
/**
* Wraps a [TagExtractor] future and processes it into a [RawSong] when completed.
* @param context [Context] required to open the audio file.
* @param rawSong [RawSong] to process.
* @author Alexander Capehart (OxygenCobalt)
*/
private class Task(context: Context, private val rawSong: RawSong) {
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely.
private val future =
MetadataRetriever.retrieveMetadata(
DefaultMediaSourceFactory(context, AudioOnlyExtractors),
MediaItem.fromUri(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))
/**
* Try to get a completed song from this [Task], if it has finished processing.
* @return A [RawSong] instance if processing has completed, null otherwise.
*/
fun get(): RawSong? {
if (!future.isDone) {
// Not done yet, nothing to do.
return null
}
val format =
try {
future.get()[0].getFormat(0)
} catch (e: Exception) {
logW("Unable to extract metadata for ${rawSong.name}")
logW(e.stackTraceToString())
null
}
if (format == null) {
logD("Nothing could be extracted for ${rawSong.name}")
return rawSong
}
val metadata = format.metadata
if (metadata != null) {
val textTags = TextTags(metadata)
populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis)
} else {
logD("No metadata could be extracted for ${rawSong.name}")
}
return rawSong
}
/**
* Complete this instance's [RawSong] with ID3v2 Text Identification Frames.
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values.
*/
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
// Song
textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { rawSong.name = it.first() }
textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
// Track.
textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { rawSong.track = it }
// Disc and it's subtitle name.
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { rawSong.disc = it }
textFrames["TSST"]?.let { rawSong.subtitle = it.first() }
// Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
// date types.
// Our hierarchy for dates is as such:
// 1. ID3v2.4 Original Date, as it resolves the "Released in X, Remastered in Y" issue
// 2. ID3v2.4 Recording Date, as it is the most common date type
// 3. ID3v2.4 Release Date, as it is the second most common date type
// 4. ID3v2.3 Original Date, as it is like #1
// 5. ID3v2.3 Release Year, as it is the most common date type
(textFrames["TDOR"]?.run { Date.from(first()) }
?: textFrames["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames))
?.let { rawSong.date = it }
// Album
textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() }
textFrames["TALB"]?.let { rawSong.albumName = it.first() }
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"]
?: textFrames["TXXX:releasetype"] ?: textFrames["GRP1"])
?.let { rawSong.releaseTypes = it }
// Artist
textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it }
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
(textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let {
rawSong.artistSortNames = it
}
// Album artist
textFrames["TXXX:musicbrainz album artist id"]?.let {
rawSong.albumArtistMusicBrainzIds = it
}
(textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let {
rawSong.albumArtistNames = it
}
(textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let {
rawSong.albumArtistSortNames = it
}
// Genre
textFrames["TCON"]?.let { rawSong.genreNames = it }
}
/**
* Parses the ID3v2.3 timestamp specification into a [Date] from the given Text Identification
* Frames.
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values.
* @return A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a
* hour/minute value from TIME. No second value is included. The latter two fields may not be
* included in they cannot be parsed. Will be null if a year value could not be parsed.
*/
private fun parseId3v23Date(textFrames: Map<String, List<String>>): Date? {
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
// is present.
val year =
textFrames["TORY"]?.run { first().toIntOrNull() }
?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
val tdat = textFrames["TDAT"]
return if (tdat != null && tdat.first().length == 4 && tdat.first().isDigitsOnly()) {
// TDAT frames consist of a 4-digit string where the first two digits are
// the month and the last two digits are the day.
val mm = tdat.first().substring(0..1).toInt()
val dd = tdat.first().substring(2..3).toInt()
val time = textFrames["TIME"]
if (time != null && time.first().length == 4 && time.first().isDigitsOnly()) {
// TIME frames consist of a 4-digit string where the first two digits are
// the hour and the last two digits are the minutes. No second value is
// possible.
val hh = time.first().substring(0..1).toInt()
val mi = time.first().substring(2..3).toInt()
// Able to return a full date.
Date.from(year, mm, dd, hh, mi)
} else {
// Unable to parse time, just return a date
Date.from(year, mm, dd)
}
} else {
// Unable to parse month/day, just return a year
return Date.from(year)
}
}
/**
* Complete this instance's [RawSong] with Vorbis comments.
* @param comments A mapping between vorbis comment names and one or more vorbis comment values.
*/
private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song
comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
comments["title"]?.let { rawSong.name = it.first() }
comments["titlesort"]?.let { rawSong.sortName = it.first() }
// Track.
parseVorbisPositionField(
comments["tracknumber"]?.first(),
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first())
?.let { rawSong.track = it }
// Disc and it's subtitle name.
parseVorbisPositionField(
comments["discnumber"]?.first(),
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first())
?.let { rawSong.disc = it }
comments["discsubtitle"]?.let { rawSong.subtitle = it.first() }
// Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such:
// 1. Original Date, as it solves the "Released in X, Remastered in Y" issue
// 2. Date, as it is the most common date type
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
// date tag that android supports, so it must be 15 years old or more!)
(comments["originaldate"]?.run { Date.from(first()) }
?: comments["date"]?.run { Date.from(first()) }
?: comments["year"]?.run { Date.from(first()) })
?.let { rawSong.date = it }
// Album
comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() }
comments["album"]?.let { rawSong.albumName = it.first() }
comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
comments["releasetype"]?.let { rawSong.releaseTypes = it }
// Artist
comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it }
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artists_sort"] ?: comments["artistsort"])?.let { rawSong.artistSortNames = it }
// Album artist
comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it }
(comments["albumartists"] ?: comments["albumartist"])?.let { rawSong.albumArtistNames = it }
(comments["albumartists_sort"] ?: comments["albumartistsort"])?.let {
rawSong.albumArtistSortNames = it
}
// Genre
comments["genre"]?.let { rawSong.genreNames = it }
}
}

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* TagUtil.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -26,6 +27,7 @@ import org.oxycblt.auxio.util.nonZeroOrNull
* Parse a multi-value tag based on the user configuration. If the value is already composed of more * Parse a multi-value tag based on the user configuration. If the value is already composed of more
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the * than one value, nothing is done. Otherwise, this function will attempt to split it based on the
* user's separator preferences. * user's separator preferences.
*
* @param settings [MusicSettings] required to obtain user separator configuration. * @param settings [MusicSettings] required to obtain user separator configuration.
* @return A new list of one or more [String]s. * @return A new list of one or more [String]s.
*/ */
@ -40,6 +42,7 @@ fun List<String>.parseMultiValue(settings: MusicSettings) =
/** /**
* Split a [String] by the given selector, automatically handling escaped characters that satisfy * Split a [String] by the given selector, automatically handling escaped characters that satisfy
* the selector. * the selector.
*
* @param selector A block that determines if the string should be split at a given character. * @param selector A block that determines if the string should be split at a given character.
* @return One or more [String]s split by the selector. * @return One or more [String]s split by the selector.
*/ */
@ -83,19 +86,22 @@ inline fun String.splitEscaped(selector: (Char) -> Boolean): List<String> {
/** /**
* Fix trailing whitespace or blank contents in a [String]. * Fix trailing whitespace or blank contents in a [String].
*
* @return A string with trailing whitespace remove,d or null if the [String] was all whitespace or * @return A string with trailing whitespace remove,d or null if the [String] was all whitespace or
* empty. * empty.
*/ */
fun String.correctWhitespace() = trim().ifBlank { null } fun String.correctWhitespace() = trim().ifBlank { null }
/** /**
* Fix trailing whitespace or blank contents within a list of [String]s. * Fix trailing whitespace or blank contents within a list of [String]s.
*
* @return A list of non-blank strings with trailing whitespace removed. * @return A list of non-blank strings with trailing whitespace removed.
*/ */
fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() } fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
/** /**
* Attempt to parse a string by the user's separator preferences. * Attempt to parse a string by the user's separator preferences.
*
* @param settings [MusicSettings] required to obtain user separator configuration. * @param settings [MusicSettings] required to obtain user separator configuration.
* @return A list of one or more [String]s that were split up by the user-defined separators. * @return A list of one or more [String]s that were split up by the user-defined separators.
*/ */
@ -109,9 +115,11 @@ private fun String.maybeParseBySeparators(settings: MusicSettings): List<String>
/** /**
* Parse an ID3v2-style position + total [String] field. These fields consist of a number and an * Parse an ID3v2-style position + total [String] field. These fields consist of a number and an
* (optional) total value delimited by a /. * (optional) total value delimited by a /.
*
* @return The position value extracted from the string field, or null if: * @return The position value extracted from the string field, or null if:
* - The position could not be parsed * - The position could not be parsed
* - The position was zeroed AND the total value was not present/zeroed * - The position was zeroed AND the total value was not present/zeroed
*
* @see transformPositionField * @see transformPositionField
*/ */
fun String.parseId3v2PositionField() = fun String.parseId3v2PositionField() =
@ -122,11 +130,13 @@ fun String.parseId3v2PositionField() =
/** /**
* Parse a vorbis-style position + total field. These fields consist of two fields for the position * Parse a vorbis-style position + total field. These fields consist of two fields for the position
* and total numbers. * and total numbers.
*
* @param pos The position value, or null if not present. * @param pos The position value, or null if not present.
* @param total The total value, if not present. * @param total The total value, if not present.
* @return The position value extracted from the field, or null if: * @return The position value extracted from the field, or null if:
* - The position could not be parsed * - The position could not be parsed
* - The position was zeroed AND the total value was not present/zeroed * - The position was zeroed AND the total value was not present/zeroed
*
* @see transformPositionField * @see transformPositionField
*/ */
fun parseVorbisPositionField(pos: String?, total: String?) = fun parseVorbisPositionField(pos: String?, total: String?) =
@ -134,6 +144,7 @@ fun parseVorbisPositionField(pos: String?, total: String?) =
/** /**
* Transform a raw position + total field into a position a way that tolerates placeholder values. * Transform a raw position + total field into a position a way that tolerates placeholder values.
*
* @param pos The position value, or null if not present. * @param pos The position value, or null if not present.
* @param total The total value, if not present. * @param total The total value, if not present.
* @return The position value extracted from the field, or null if: * @return The position value extracted from the field, or null if:
@ -151,6 +162,7 @@ fun transformPositionField(pos: Int?, total: Int?) =
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer * Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer
* representations of genre fields into their named counterparts, and split up singular ID3v2-style * representations of genre fields into their named counterparts, and split up singular ID3v2-style
* integer genre fields into one or more genres. * integer genre fields into one or more genres.
*
* @param settings [MusicSettings] required to obtain user separator configuration. * @param settings [MusicSettings] required to obtain user separator configuration.
* @return A list of one or more genre names.. * @return A list of one or more genre names..
*/ */
@ -164,6 +176,7 @@ fun List<String>.parseId3GenreNames(settings: MusicSettings) =
/** /**
* Parse a single ID3v1/ID3v2 integer genre field into their named representations. * Parse a single ID3v1/ID3v2 integer genre field into their named representations.
*
* @param settings [MusicSettings] required to obtain user separator configuration. * @param settings [MusicSettings] required to obtain user separator configuration.
* @return A list of one or more genre names. * @return A list of one or more genre names.
*/ */
@ -172,8 +185,9 @@ private fun String.parseId3MultiValueGenre(settings: MusicSettings) =
/** /**
* Parse an ID3v1 integer genre field. * Parse an ID3v1 integer genre field.
*
* @return A named genre if the field is a valid integer, "Cover" or "Remix" if the field is * @return A named genre if the field is a valid integer, "Cover" or "Remix" if the field is
* "CR"/"RX" respectively, and nothing if the field is not a valid ID3v1 integer genre. * "CR"/"RX" respectively, and nothing if the field is not a valid ID3v1 integer genre.
*/ */
private fun String.parseId3v1Genre(): String? { private fun String.parseId3v1Genre(): String? {
// ID3v1 genres are a plain integer value without formatting, so in that case // ID3v1 genres are a plain integer value without formatting, so in that case
@ -200,6 +214,7 @@ private val ID3V2_GENRE_RE = Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?")
/** /**
* Parse an ID3v2 integer genre field, which has support for multiple genre values and combined * Parse an ID3v2 integer genre field, which has support for multiple genre values and combined
* named/integer genres. * named/integer genres.
*
* @return A list of one or more genres, or null if the field is not a valid ID3v2 integer genre. * @return A list of one or more genres, or null if the field is not a valid ID3v2 integer genre.
*/ */
private fun String.parseId3v2Genre(): List<String>? { private fun String.parseId3v2Genre(): List<String>? {

View file

@ -0,0 +1,299 @@
/*
* Copyright (c) 2023 Auxio Project
* TagWorker.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.metadata
import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.TrackGroupArray
import java.util.concurrent.Future
import javax.inject.Inject
import org.oxycblt.auxio.music.model.RawSong
import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/**
* An processing abstraction over the [MetadataRetriever] and [TextTags] workflow that operates on
* [RawSong] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface TagWorker {
/**
* Poll to see if this worker is done processing.
*
* @return A completed [RawSong] if done, null otherwise.
*/
fun poll(): RawSong?
/** Factory for new [TagWorker] jobs. */
interface Factory {
/**
* Create a new [TagWorker] to complete the given [RawSong].
*
* @param rawSong The [RawSong] to assign a new [TagWorker] to.
* @return A new [TagWorker] wrapping the given [RawSong].
*/
fun create(rawSong: RawSong): TagWorker
}
}
class TagWorkerImpl
private constructor(private val rawSong: RawSong, private val future: Future<TrackGroupArray>) :
TagWorker {
/**
* Try to get a completed song from this [TagWorker], if it has finished processing.
*
* @return A [RawSong] instance if processing has completed, null otherwise.
*/
override fun poll(): RawSong? {
if (!future.isDone) {
// Not done yet, nothing to do.
return null
}
val format =
try {
future.get()[0].getFormat(0)
} catch (e: Exception) {
logW("Unable to extract metadata for ${rawSong.name}")
logW(e.stackTraceToString())
null
}
if (format == null) {
logD("Nothing could be extracted for ${rawSong.name}")
return rawSong
}
val metadata = format.metadata
if (metadata != null) {
val textTags = TextTags(metadata)
populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis)
} else {
logD("No metadata could be extracted for ${rawSong.name}")
}
return rawSong
}
/**
* Complete this instance's [RawSong] with ID3v2 Text Identification Frames.
*
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values.
*/
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
// Song
textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { rawSong.name = it.first() }
textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
// Track.
textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { rawSong.track = it }
// Disc and it's subtitle name.
textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { rawSong.disc = it }
textFrames["TSST"]?.let { rawSong.subtitle = it.first() }
// Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
// date types.
// Our hierarchy for dates is as such:
// 1. ID3v2.4 Original Date, as it resolves the "Released in X, Remastered in Y" issue
// 2. ID3v2.4 Recording Date, as it is the most common date type
// 3. ID3v2.4 Release Date, as it is the second most common date type
// 4. ID3v2.3 Original Date, as it is like #1
// 5. ID3v2.3 Release Year, as it is the most common date type
(textFrames["TDOR"]?.run { Date.from(first()) }
?: textFrames["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames))
?.let { rawSong.date = it }
// Album
textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() }
textFrames["TALB"]?.let { rawSong.albumName = it.first() }
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"]
?: textFrames["TXXX:releasetype"] ?: textFrames["GRP1"])
?.let { rawSong.releaseTypes = it }
// Artist
textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it }
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
(textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let {
rawSong.artistSortNames = it
}
// Album artist
textFrames["TXXX:musicbrainz album artist id"]?.let {
rawSong.albumArtistMusicBrainzIds = it
}
(textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let {
rawSong.albumArtistNames = it
}
(textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let {
rawSong.albumArtistSortNames = it
}
// Genre
textFrames["TCON"]?.let { rawSong.genreNames = it }
// Compilation Flag
(textFrames["TCMP"]
?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"])
?.let {
if (it.size != 1 || it[0] != "1") return@let
rawSong.albumArtistNames =
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
}
}
/**
* Parses the ID3v2.3 timestamp specification into a [Date] from the given Text Identification
* Frames.
*
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values.
* @return A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a
* hour/minute value from TIME. No second value is included. The latter two fields may not be
* included in they cannot be parsed. Will be null if a year value could not be parsed.
*/
private fun parseId3v23Date(textFrames: Map<String, List<String>>): Date? {
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
// is present.
val year =
textFrames["TORY"]?.run { first().toIntOrNull() }
?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
val tdat = textFrames["TDAT"]
return if (tdat != null && tdat.first().length == 4 && tdat.first().isDigitsOnly()) {
// TDAT frames consist of a 4-digit string where the first two digits are
// the month and the last two digits are the day.
val mm = tdat.first().substring(0..1).toInt()
val dd = tdat.first().substring(2..3).toInt()
val time = textFrames["TIME"]
if (time != null && time.first().length == 4 && time.first().isDigitsOnly()) {
// TIME frames consist of a 4-digit string where the first two digits are
// the hour and the last two digits are the minutes. No second value is
// possible.
val hh = time.first().substring(0..1).toInt()
val mi = time.first().substring(2..3).toInt()
// Able to return a full date.
Date.from(year, mm, dd, hh, mi)
} else {
// Unable to parse time, just return a date
Date.from(year, mm, dd)
}
} else {
// Unable to parse month/day, just return a year
return Date.from(year)
}
}
/**
* Complete this instance's [RawSong] with Vorbis comments.
*
* @param comments A mapping between vorbis comment names and one or more vorbis comment values.
*/
private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song
comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
comments["title"]?.let { rawSong.name = it.first() }
comments["titlesort"]?.let { rawSong.sortName = it.first() }
// Track.
parseVorbisPositionField(
comments["tracknumber"]?.first(),
(comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first())
?.let { rawSong.track = it }
// Disc and it's subtitle name.
parseVorbisPositionField(
comments["discnumber"]?.first(),
(comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first())
?.let { rawSong.disc = it }
comments["discsubtitle"]?.let { rawSong.subtitle = it.first() }
// Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such:
// 1. Original Date, as it solves the "Released in X, Remastered in Y" issue
// 2. Date, as it is the most common date type
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
// date tag that android supports, so it must be 15 years old or more!)
(comments["originaldate"]?.run { Date.from(first()) }
?: comments["date"]?.run { Date.from(first()) }
?: comments["year"]?.run { Date.from(first()) })
?.let { rawSong.date = it }
// Album
comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() }
comments["album"]?.let { rawSong.albumName = it.first() }
comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
comments["releasetype"]?.let { rawSong.releaseTypes = it }
// Artist
comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it }
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artists_sort"] ?: comments["artistsort"])?.let { rawSong.artistSortNames = it }
// Album artist
comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it }
(comments["albumartists"] ?: comments["albumartist"])?.let { rawSong.albumArtistNames = it }
(comments["albumartists_sort"] ?: comments["albumartistsort"])?.let {
rawSong.albumArtistSortNames = it
}
// Genre
comments["genre"]?.let { rawSong.genreNames = it }
// Compilation Flag
(comments["compilation"] ?: comments["itunescompilation"])?.let {
if (it.size != 1 || it[0] != "1") return@let
rawSong.albumArtistNames =
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
}
}
class Factory @Inject constructor(private val mediaSourceFactory: MediaSource.Factory) :
TagWorker.Factory {
override fun create(rawSong: RawSong) =
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely.
TagWorkerImpl(
rawSong,
MetadataRetriever.retrieveMetadata(
mediaSourceFactory,
MediaItem.fromUri(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }
.toAudioUri())))
}
private companion object {
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
val COMPILATION_RELEASE_TYPES = listOf("compilation")
}
}

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* TextTags.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -24,6 +25,7 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
/** /**
* Processing wrapper for [Metadata] that allows organized access to text-based audio tags. * Processing wrapper for [Metadata] that allows organized access to text-based audio tags.
*
* @param metadata The [Metadata] to wrap. * @param metadata The [Metadata] to wrap.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@ -79,8 +81,9 @@ class TextTags(metadata: Metadata) {
/** /**
* Copies and sanitizes a possibly invalid string outputted from ExoPlayer. * Copies and sanitizes a possibly invalid string outputted from ExoPlayer.
*
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with * @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
* the Unicode replacement byte sequence. * the Unicode replacement byte sequence.
*/ */
private fun String.sanitize() = String(encodeToByteArray()) private fun String.sanitize() = String(encodeToByteArray())
} }

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* Library.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -47,14 +48,16 @@ interface Library {
/** /**
* Finds a [Music] item [T] in the library by it's [Music.UID]. * Finds a [Music] item [T] in the library by it's [Music.UID].
*
* @param uid The [Music.UID] to search for. * @param uid The [Music.UID] to search for.
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or
* the [Music.UID] did not correspond to a [T]. * the [Music.UID] did not correspond to a [T].
*/ */
fun <T : Music> find(uid: Music.UID): T? fun <T : Music> find(uid: Music.UID): T?
/** /**
* Convert a [Song] from an another library into a [Song] in this [Library]. * Convert a [Song] from an another library into a [Song] in this [Library].
*
* @param song The [Song] to convert. * @param song The [Song] to convert.
* @return The analogous [Song] in this [Library], or null if it does not exist. * @return The analogous [Song] in this [Library], or null if it does not exist.
*/ */
@ -62,6 +65,7 @@ interface Library {
/** /**
* Convert a [MusicParent] from an another library into a [MusicParent] in this [Library]. * Convert a [MusicParent] from an another library into a [MusicParent] in this [Library].
*
* @param parent The [MusicParent] to convert. * @param parent The [MusicParent] to convert.
* @return The analogous [Album] in this [Library], or null if it does not exist. * @return The analogous [Album] in this [Library], or null if it does not exist.
*/ */
@ -69,6 +73,7 @@ interface Library {
/** /**
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri]. * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
*
* @param context [Context] required to analyze the [Uri]. * @param context [Context] required to analyze the [Uri].
* @param uri [Uri] to search for. * @param uri [Uri] to search for.
* @return A [Song] corresponding to the given [Uri], or null if one could not be found. * @return A [Song] corresponding to the given [Uri], or null if one could not be found.
@ -78,6 +83,7 @@ interface Library {
companion object { companion object {
/** /**
* Create an instance of [Library]. * Create an instance of [Library].
*
* @param rawSongs [RawSong]s to create the library out of. * @param rawSongs [RawSong]s to create the library out of.
* @param settings [MusicSettings] required. * @param settings [MusicSettings] required.
*/ */
@ -117,9 +123,10 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
/** /**
* Finds a [Music] item [T] in the library by it's [Music.UID]. * Finds a [Music] item [T] in the library by it's [Music.UID].
*
* @param uid The [Music.UID] to search for. * @param uid The [Music.UID] to search for.
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or
* the [Music.UID] did not correspond to a [T]. * the [Music.UID] did not correspond to a [T].
*/ */
@Suppress("UNCHECKED_CAST") override fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T @Suppress("UNCHECKED_CAST") override fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
@ -130,21 +137,22 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
override fun findSongForUri(context: Context, uri: Uri) = override fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery( context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor -> uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst() cursor.moveToFirst()
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a // 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. // song. Do what we can to hopefully find the song the user wanted to open.
val displayName = val displayName =
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
songs.find { it.path.name == displayName && it.size == size } songs.find { it.path.name == displayName && it.size == size }
} }
/** /**
* Build a list [SongImpl]s from the given [RawSong]. * Build a list [SongImpl]s from the given [RawSong].
*
* @param rawSongs The [RawSong]s to build the [SongImpl]s from. * @param rawSongs The [RawSong]s to build the [SongImpl]s from.
* @param settings [MusicSettings] to obtain user parsing configuration. * @param settings [MusicSettings] to obtain user parsing configuration.
* @return A sorted list of [SongImpl]s derived from the [RawSong] that should be suitable for * @return A sorted list of [SongImpl]s derived from the [RawSong] that should be suitable for
* grouping. * grouping.
*/ */
private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings) = private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings) =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
@ -152,11 +160,12 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
/** /**
* Build a list of [Album]s from the given [Song]s. * Build a list of [Album]s from the given [Song]s.
*
* @param songs The [Song]s to build [Album]s from. These will be linked with their respective * @param songs The [Song]s to build [Album]s from. These will be linked with their respective
* [Album]s when created. * [Album]s when created.
* @param settings [MusicSettings] to obtain user parsing configuration. * @param settings [MusicSettings] to obtain user parsing configuration.
* @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked * @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked
* with parent [Artist] instances in order to be usable. * with parent [Artist] instances in order to be usable.
*/ */
private fun buildAlbums(songs: List<SongImpl>, settings: MusicSettings): List<AlbumImpl> { private fun buildAlbums(songs: List<SongImpl>, settings: MusicSettings): List<AlbumImpl> {
// Group songs by their singular raw album, then map the raw instances and their // Group songs by their singular raw album, then map the raw instances and their
@ -171,15 +180,16 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
* Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as * Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as
* they group into [Artist] instances much differently, with [Song]s being grouped primarily by * they group into [Artist] instances much differently, with [Song]s being grouped primarily by
* artist names, and [Album]s being grouped primarily by album artist names. * artist names, and [Album]s being grouped primarily by album artist names.
*
* @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of * @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of
* one or more [Artist] instances. These will be linked with their respective [Artist]s when * one or more [Artist] instances. These will be linked with their respective [Artist]s when
* created. * created.
* @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of * @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of
* one or more [Artist] instances. These will be linked with their respective [Artist]s when * one or more [Artist] instances. These will be linked with their respective [Artist]s when
* created. * created.
* @param settings [MusicSettings] to obtain user parsing configuration. * @param settings [MusicSettings] to obtain user parsing configuration.
* @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings * @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
* of [Song]s and [Album]s. * of [Song]s and [Album]s.
*/ */
private fun buildArtists( private fun buildArtists(
songs: List<SongImpl>, songs: List<SongImpl>,
@ -210,9 +220,10 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
/** /**
* Group up [Song]s into [Genre] instances. * Group up [Song]s into [Genre] instances.
*
* @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of * @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of
* one or more [Genre] instances. These will be linked with their respective [Genre]s when * one or more [Genre] instances. These will be linked with their respective [Genre]s when
* created. * created.
* @param settings [MusicSettings] to obtain user parsing configuration. * @param settings [MusicSettings] to obtain user parsing configuration.
* @return A non-empty list of [Genre]s. * @return A non-empty list of [Genre]s.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* MusicImpl.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -20,17 +21,9 @@ package org.oxycblt.auxio.music.model
import android.content.Context import android.content.Context
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import java.security.MessageDigest import java.security.MessageDigest
import java.text.CollationKey
import java.text.Collator
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType import org.oxycblt.auxio.music.metadata.ReleaseType
@ -46,14 +39,15 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* Library-backed implementation of [Song]. * Library-backed implementation of [Song].
*
* @param rawSong The [RawSong] to derive the member data from. * @param rawSong The [RawSong] to derive the member data from.
* @param musicSettings [MusicSettings] to for user parsing configuration. * @param musicSettings [MusicSettings] to for user parsing configuration.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song { class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
override val uid = override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID. // Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) } rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) }
?: Music.UID.auxio(MusicMode.SONGS) { ?: Music.UID.auxio(MusicMode.SONGS) {
// Song UIDs are based on the raw data without parsing so that they remain // 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 // consistent across music setting changes. Parents are not held up to the
@ -70,7 +64,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
} }
override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" } override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" }
override val rawSortName = rawSong.sortName override val rawSortName = rawSong.sortName
override val collationKey = makeCollationKey(musicSettings) override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
override val track = rawSong.track override val track = rawSong.track
@ -164,6 +158,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
/** /**
* Links this [Song] with a parent [Album]. * Links this [Song] with a parent [Album].
*
* @param album The parent [Album] to link to. * @param album The parent [Album] to link to.
*/ */
fun link(album: AlbumImpl) { fun link(album: AlbumImpl) {
@ -172,6 +167,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
/** /**
* Links this [Song] with a parent [Artist]. * Links this [Song] with a parent [Artist].
*
* @param artist The parent [Artist] to link to. * @param artist The parent [Artist] to link to.
*/ */
fun link(artist: ArtistImpl) { fun link(artist: ArtistImpl) {
@ -180,6 +176,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
/** /**
* Links this [Song] with a parent [Genre]. * Links this [Song] with a parent [Genre].
*
* @param genre The parent [Genre] to link to. * @param genre The parent [Genre] to link to.
*/ */
fun link(genre: GenreImpl) { fun link(genre: GenreImpl) {
@ -188,6 +185,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
/** /**
* Perform final validation and organization on this instance. * Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Song]. * @return This instance upcasted to [Song].
*/ */
fun finalize(): Song { fun finalize(): Song {
@ -218,10 +216,11 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
/** /**
* Library-backed implementation of [Album]. * Library-backed implementation of [Album].
*
* @param rawAlbum The [RawAlbum] to derive the member data from. * @param rawAlbum The [RawAlbum] to derive the member data from.
* @param musicSettings [MusicSettings] to for user parsing configuration. * @param musicSettings [MusicSettings] to for user parsing configuration.
* @param songs The [Song]s that are a part of this [Album]. These items will be linked to this * @param songs The [Song]s that are a part of this [Album]. These items will be linked to this
* [Album]. * [Album].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class AlbumImpl( class AlbumImpl(
@ -230,8 +229,8 @@ class AlbumImpl(
override val songs: List<SongImpl> override val songs: List<SongImpl>
) : Album { ) : Album {
override val uid = override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID. // Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) } rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) }
?: Music.UID.auxio(MusicMode.ALBUMS) { ?: Music.UID.auxio(MusicMode.ALBUMS) {
// Hash based on only names despite the presence of a date to increase stability. // 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 // I don't know if there is any situation where an artist will have two albums with
@ -241,7 +240,7 @@ class AlbumImpl(
} }
override val rawName = rawAlbum.name override val rawName = rawAlbum.name
override val rawSortName = rawAlbum.sortName override val rawSortName = rawAlbum.sortName
override val collationKey = makeCollationKey(musicSettings) override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
override val dates = Date.Range.from(songs.mapNotNull { it.date }) override val dates = Date.Range.from(songs.mapNotNull { it.date })
@ -286,6 +285,7 @@ class AlbumImpl(
/** /**
* Links this [Album] with a parent [Artist]. * Links this [Album] with a parent [Artist].
*
* @param artist The parent [Artist] to link to. * @param artist The parent [Artist] to link to.
*/ */
fun link(artist: ArtistImpl) { fun link(artist: ArtistImpl) {
@ -294,6 +294,7 @@ class AlbumImpl(
/** /**
* Perform final validation and organization on this instance. * Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Album]. * @return This instance upcasted to [Album].
*/ */
fun finalize(): Album { fun finalize(): Album {
@ -313,11 +314,12 @@ class AlbumImpl(
/** /**
* Library-backed implementation of [Artist]. * Library-backed implementation of [Artist].
*
* @param rawArtist The [RawArtist] to derive the member data from. * @param rawArtist The [RawArtist] to derive the member data from.
* @param musicSettings [MusicSettings] to for user parsing configuration. * @param musicSettings [MusicSettings] to for user parsing configuration.
* @param songAlbums A list of the [Song]s and [Album]s that are a part of this [Artist] , either * @param songAlbums A list of the [Song]s and [Album]s that are a part of this [Artist] , either
* through artist or album artist tags. Providing [Song]s to the artist is optional. These instances * through artist or album artist tags. Providing [Song]s to the artist is optional. These
* will be linked to this [Artist]. * instances will be linked to this [Artist].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ArtistImpl( class ArtistImpl(
@ -326,12 +328,12 @@ class ArtistImpl(
songAlbums: List<Music> songAlbums: List<Music>
) : Artist { ) : Artist {
override val uid = override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID. // Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) } rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) } ?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) }
override val rawName = rawArtist.name override val rawName = rawArtist.name
override val rawSortName = rawArtist.sortName override val rawSortName = rawArtist.sortName
override val collationKey = makeCollationKey(musicSettings) override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) }
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
override val songs: List<Song> override val songs: List<Song>
@ -379,14 +381,16 @@ class ArtistImpl(
* Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist] * Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist]
* list. This can be used to create a consistent ordering within child [Artist] lists based on * list. This can be used to create a consistent ordering within child [Artist] lists based on
* the original tag order. * the original tag order.
*
* @param rawArtists The [RawArtist] instances to check. It is assumed that this [Artist]'s * @param rawArtists The [RawArtist] instances to check. It is assumed that this [Artist]'s
* [RawArtist] will be within the list. * [RawArtist] will be within the list.
* @return The index of the [Artist]'s [RawArtist] within the list. * @return The index of the [Artist]'s [RawArtist] within the list.
*/ */
fun getOriginalPositionIn(rawArtists: List<RawArtist>) = rawArtists.indexOf(rawArtist) fun getOriginalPositionIn(rawArtists: List<RawArtist>) = rawArtists.indexOf(rawArtist)
/** /**
* Perform final validation and organization on this instance. * Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Artist]. * @return This instance upcasted to [Artist].
*/ */
fun finalize(): Artist { fun finalize(): Artist {
@ -400,6 +404,7 @@ class ArtistImpl(
} }
/** /**
* Library-backed implementation of [Genre]. * Library-backed implementation of [Genre].
*
* @param rawGenre [RawGenre] to derive the member data from. * @param rawGenre [RawGenre] to derive the member data from.
* @param musicSettings [MusicSettings] to for user parsing configuration. * @param musicSettings [MusicSettings] to for user parsing configuration.
* @param songs Child [SongImpl]s of this instance. * @param songs Child [SongImpl]s of this instance.
@ -413,7 +418,7 @@ class GenreImpl(
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) } override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
override val rawName = rawGenre.name override val rawName = rawGenre.name
override val rawSortName = rawName override val rawSortName = rawName
override val collationKey = makeCollationKey(musicSettings) override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) }
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
override val albums: List<Album> override val albums: List<Album>
@ -450,14 +455,16 @@ class GenreImpl(
* Returns the original position of this [Genre]'s [RawGenre] within the given [RawGenre] list. * Returns the original position of this [Genre]'s [RawGenre] within the given [RawGenre] list.
* This can be used to create a consistent ordering within child [Genre] lists based on the * This can be used to create a consistent ordering within child [Genre] lists based on the
* original tag order. * original tag order.
*
* @param rawGenres The [RawGenre] instances to check. It is assumed that this [Genre] 's * @param rawGenres The [RawGenre] instances to check. It is assumed that this [Genre] 's
* [RawGenre] will be within the list. * [RawGenre] will be within the list.
* @return The index of the [Genre]'s [RawGenre] within the list. * @return The index of the [Genre]'s [RawGenre] within the list.
*/ */
fun getOriginalPositionIn(rawGenres: List<RawGenre>) = rawGenres.indexOf(rawGenre) fun getOriginalPositionIn(rawGenres: List<RawGenre>) = rawGenres.indexOf(rawGenre)
/** /**
* Perform final validation and organization on this instance. * Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Genre]. * @return This instance upcasted to [Genre].
*/ */
fun finalize(): Music { fun finalize(): Music {
@ -468,6 +475,7 @@ class GenreImpl(
/** /**
* Update a [MessageDigest] with a lowercase [String]. * Update a [MessageDigest] with a lowercase [String].
*
* @param string The [String] to hash. If null, it will not be hashed. * @param string The [String] to hash. If null, it will not be hashed.
*/ */
@VisibleForTesting @VisibleForTesting
@ -481,6 +489,7 @@ fun MessageDigest.update(string: String?) {
/** /**
* Update a [MessageDigest] with the string representation of a [Date]. * Update a [MessageDigest] with the string representation of a [Date].
*
* @param date The [Date] to hash. If null, nothing will be done. * @param date The [Date] to hash. If null, nothing will be done.
*/ */
@VisibleForTesting @VisibleForTesting
@ -494,6 +503,7 @@ fun MessageDigest.update(date: Date?) {
/** /**
* Update a [MessageDigest] with the lowercase versions of all of the input [String]s. * Update a [MessageDigest] with the lowercase versions of all of the input [String]s.
*
* @param strings The [String]s to hash. If a [String] is null, it will not be hashed. * @param strings The [String]s to hash. If a [String] is null, it will not be hashed.
*/ */
@VisibleForTesting @VisibleForTesting
@ -503,6 +513,7 @@ fun MessageDigest.update(strings: List<String?>) {
/** /**
* Update a [MessageDigest] with the little-endian bytes of a [Int]. * Update a [MessageDigest] with the little-endian bytes of a [Int].
*
* @param n The [Int] to write. If null, nothing will be done. * @param n The [Int] to write. If null, nothing will be done.
*/ */
@VisibleForTesting @VisibleForTesting
@ -513,30 +524,3 @@ fun MessageDigest.update(n: Int?) {
update(0) update(0)
} }
} }
/** Cached collator instance re-used with [makeCollationKey]. */
private val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
/**
* Provided implementation to create a [CollationKey] in the way described by [Music.collationKey].
* This should be used in all overrides of all [CollationKey].
* @param musicSettings [MusicSettings] required for user parsing configuration.
* @return A [CollationKey] that follows the specification described by [Music.collationKey].
*/
private fun Music.makeCollationKey(musicSettings: MusicSettings): CollationKey? {
var sortName = (rawSortName ?: rawName) ?: return null
if (musicSettings.automaticSortNames) {
sortName =
sortName.run {
when {
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
else -> this
}
}
}
return COLLATOR.getCollationKey(sortName)
}

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* RawMusic.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -24,6 +25,7 @@ import org.oxycblt.auxio.music.storage.Directory
/** /**
* Raw information about a [SongImpl] obtained from the filesystem/Extractor instances. * Raw information about a [SongImpl] obtained from the filesystem/Extractor instances.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class RawSong( class RawSong(
@ -88,6 +90,7 @@ class RawSong(
/** /**
* Raw information about an [AlbumImpl] obtained from the component [SongImpl] instances. * Raw information about an [AlbumImpl] obtained from the component [SongImpl] instances.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class RawAlbum( class RawAlbum(
@ -134,6 +137,7 @@ class RawAlbum(
/** /**
* Raw information about an [ArtistImpl] obtained from the component [SongImpl] and [AlbumImpl] * Raw information about an [ArtistImpl] obtained from the component [SongImpl] and [AlbumImpl]
* instances. * instances.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class RawArtist( class RawArtist(
@ -175,6 +179,7 @@ class RawArtist(
/** /**
* Raw information about a [GenreImpl] obtained from the component [SongImpl] instances. * Raw information about a [GenreImpl] obtained from the component [SongImpl] instances.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class RawGenre( class RawGenre(

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2021 Auxio Project * Copyright (c) 2021 Auxio Project
* DirectoryAdapter.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -27,6 +28,7 @@ import org.oxycblt.auxio.util.inflater
/** /**
* [RecyclerView.Adapter] that manages a list of [Directory] instances. * [RecyclerView.Adapter] that manages a list of [Directory] instances.
*
* @param listener A [DirectoryAdapter.Listener] to bind interactions to. * @param listener A [DirectoryAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@ -48,6 +50,7 @@ class DirectoryAdapter(private val listener: Listener) :
/** /**
* Add a [Directory] to the end of the list. * Add a [Directory] to the end of the list.
*
* @param dir The [Directory] to add. * @param dir The [Directory] to add.
*/ */
fun add(dir: Directory) { fun add(dir: Directory) {
@ -61,6 +64,7 @@ class DirectoryAdapter(private val listener: Listener) :
/** /**
* Add a list of [Directory] instances to the end of the list. * Add a list of [Directory] instances to the end of the list.
*
* @param dirs The [Directory instances to add. * @param dirs The [Directory instances to add.
*/ */
fun addAll(dirs: List<Directory>) { fun addAll(dirs: List<Directory>) {
@ -71,6 +75,7 @@ class DirectoryAdapter(private val listener: Listener) :
/** /**
* Remove a [Directory] from the list. * Remove a [Directory] from the list.
*
* @param dir The [Directory] to remove. Must exist in the list. * @param dir The [Directory] to remove. Must exist in the list.
*/ */
fun remove(dir: Directory) { fun remove(dir: Directory) {
@ -87,12 +92,14 @@ class DirectoryAdapter(private val listener: Listener) :
/** /**
* A [RecyclerView.Recycler] that displays a [Directory]. Use [from] to create an instance. * A [RecyclerView.Recycler] that displays a [Directory]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) : class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
DialogRecyclerView.ViewHolder(binding.root) { DialogRecyclerView.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
*
* @param dir The new [Directory] to bind. * @param dir The new [Directory] to bind.
* @param listener A [DirectoryAdapter.Listener] to bind interactions to. * @param listener A [DirectoryAdapter.Listener] to bind interactions to.
*/ */
@ -104,6 +111,7 @@ class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBi
companion object { companion object {
/** /**
* Create a new instance. * Create a new instance.
*
* @param parent The parent to inflate this instance from. * @param parent The parent to inflate this instance from.
* @return A new instance. * @return A new instance.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
* Copyright (c) 2022 Auxio Project * Copyright (c) 2022 Auxio Project
* Filesystem.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -28,6 +29,7 @@ import org.oxycblt.auxio.R
/** /**
* A full absolute path to a file. Only intended for display purposes. For accessing files, URIs are * A full absolute path to a file. Only intended for display purposes. For accessing files, URIs are
* preferred in all cases due to scoped storage limitations. * preferred in all cases due to scoped storage limitations.
*
* @param name The name of the file. * @param name The name of the file.
* @param parent The parent [Directory] of the file. * @param parent The parent [Directory] of the file.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
@ -36,6 +38,7 @@ data class Path(val name: String, val parent: Directory)
/** /**
* A volume-aware relative path to a directory. * A volume-aware relative path to a directory.
*
* @param volume The [StorageVolume] that the [Directory] is contained in. * @param volume The [StorageVolume] that the [Directory] is contained in.
* @param relativePath The relative path from within the [StorageVolume] to the [Directory]. * @param relativePath The relative path from within the [StorageVolume] to the [Directory].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
@ -43,6 +46,7 @@ data class Path(val name: String, val parent: Directory)
class Directory private constructor(val volume: StorageVolume, val relativePath: String) { class Directory private constructor(val volume: StorageVolume, val relativePath: String) {
/** /**
* Resolve the [Directory] instance into a human-readable path name. * Resolve the [Directory] instance into a human-readable path name.
*
* @param context [Context] required to obtain volume descriptions. * @param context [Context] required to obtain volume descriptions.
* @return A human-readable path. * @return A human-readable path.
* @see StorageVolume.getDescription * @see StorageVolume.getDescription
@ -55,8 +59,9 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
* violation of the document tree URI contract, but it's also the only one can sensibly work * violation of the document tree URI contract, but it's also the only one can sensibly work
* with these uris in the UI, and it doesn't exactly matter since we never write or read to * with these uris in the UI, and it doesn't exactly matter since we never write or read to
* directory. * directory.
*
* @return A URI [String] abiding by the document tree specification, or null if the [Directory] * @return A URI [String] abiding by the document tree specification, or null if the [Directory]
* is not valid. * is not valid.
*/ */
fun toDocumentTreeUri() = fun toDocumentTreeUri() =
// Document tree URIs consist of a prefixed volume name followed by a relative path. // Document tree URIs consist of a prefixed volume name followed by a relative path.
@ -84,9 +89,10 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
/** /**
* Create a new directory instance from the given components. * Create a new directory instance from the given components.
*
* @param volume The [StorageVolume] that the [Directory] is contained in. * @param volume The [StorageVolume] that the [Directory] is contained in.
* @param relativePath The relative path from within the [StorageVolume] to the [Directory]. * @param relativePath The relative path from within the [StorageVolume] to the [Directory].
* Will be stripped of any trailing separators for a consistent internal representation. * Will be stripped of any trailing separators for a consistent internal representation.
* @return A new [Directory] created from the components. * @return A new [Directory] created from the components.
*/ */
fun from(volume: StorageVolume, relativePath: String) = fun from(volume: StorageVolume, relativePath: String) =
@ -97,8 +103,9 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
* Create a new directory from a document tree URI. This is a huge violation of the document * Create a new directory from a document tree URI. This is a huge violation of the document
* tree URI contract, but it's also the only one can sensibly work with these uris in the * tree URI contract, but it's also the only one can sensibly work with these uris in the
* UI, and it doesn't exactly matter since we never write or read directory. * UI, and it doesn't exactly matter since we never write or read directory.
*
* @param storageManager [StorageManager] in order to obtain the [StorageVolume] specified * @param storageManager [StorageManager] in order to obtain the [StorageVolume] specified
* in the given URI. * in the given URI.
* @param uri The URI string to parse into a [Directory]. * @param uri The URI string to parse into a [Directory].
* @return A new [Directory] parsed from the URI, or null if the URI is not valid. * @return A new [Directory] parsed from the URI, or null if the URI is not valid.
*/ */
@ -123,26 +130,29 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
/** /**
* Represents the configuration for specific directories to filter to/from when loading music. * Represents the configuration for specific directories to filter to/from when loading music.
*
* @param dirs A list of [Directory] instances. How these are interpreted depends on [shouldInclude] * @param dirs A list of [Directory] instances. How these are interpreted depends on [shouldInclude]
* @param shouldInclude True if the library should only load from the [Directory] instances, false * @param shouldInclude True if the library should only load from the [Directory] instances, false
* if the library should not load from the [Directory] instances. * if the library should not load from the [Directory] instances.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolean) data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolean)
/** /**
* A mime type of a file. Only intended for display. * A mime type of a file. Only intended for display.
*
* @param fromExtension The mime type obtained by analyzing the file extension. * @param fromExtension The mime type obtained by analyzing the file extension.
* @param fromFormat The mime type obtained by analyzing the file format. Null if could not be * @param fromFormat The mime type obtained by analyzing the file format. Null if could not be
* obtained. * obtained.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class MimeType(val fromExtension: String, val fromFormat: String?) { data class MimeType(val fromExtension: String, val fromFormat: String?) {
/** /**
* Resolve the mime type into a human-readable format name, such as "Ogg Vorbis". * Resolve the mime type into a human-readable format name, such as "Ogg Vorbis".
*
* @param context [Context] required to obtain human-readable strings. * @param context [Context] required to obtain human-readable strings.
* @return A human-readable name for this mime type. Will first try [fromFormat], then falling * @return A human-readable name for this mime type. Will first try [fromFormat], then falling
* back to [fromExtension], and then null if that fails. * back to [fromExtension], and then null if that fails.
*/ */
fun resolveName(context: Context): String? { fun resolveName(context: Context): String? {
// We try our best to produce a more readable name for the common audio formats. // We try our best to produce a more readable name for the common audio formats.

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