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
id: logs
attributes:
label: Relevant log output
label: Bug report
description: |
If possible, provide a stack trace or a Logcat. This can help identify the issue.
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
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.
validations:
required: true
- type: checkboxes

View file

@ -1,5 +1,29 @@
# 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
#### What's New
@ -24,7 +48,6 @@ while selecting it.
#### Dev/Meta
- Started using dependency injection
- Started code obsfucation
- Only bundle audio-related extractors with ExoPlayer
- Switched to Room for database management
- 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>
<h4 align="center">A simple, rational music player for android.</h4>
<p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.3">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.3&color=0D5AF5">
<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.4&color=64B5F6&style=flat">
</a>
<a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
</a>
<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>
<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>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a></h4>
<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
the build process:
1. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
download in the external code.
2. You are **unable** to build this project on windows, as the custom ExoPlayer build runs shell scripts that
1. `cmake` and `ninja-build` must be installed before building the project.
2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
download the external code.
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.
## Contributing

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) $today.year Auxio Project
* $FILE 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

View file

@ -20,8 +20,8 @@ android {
defaultConfig {
applicationId namespace
versionName "3.0.3"
versionCode 27
versionName "3.0.4"
versionCode 28
minSdk 21
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 {
viewBinding true
}
@ -67,7 +75,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.6.4"
// --- SUPPORT ---
@ -79,13 +88,13 @@ dependencies {
implementation "androidx.fragment:fragment-ktx:1.5.5"
// UI
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.recyclerview:recyclerview:1.3.0"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
implementation 'androidx.core:core-ktx:+'
implementation 'androidx.core:core-ktx:1.9.0'
// 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-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@ -128,6 +137,7 @@ dependencies {
kapt "com.google.dagger:dagger-compiler:$dagger_version"
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
// Testing
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation "junit:junit:4.13.2"

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* StubTest.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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* Auxio.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
@ -30,6 +31,7 @@ import org.oxycblt.auxio.ui.UISettings
/**
* A simple, rational music player for android.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@HiltAndroidApp

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* IntegerTable.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
@ -20,6 +21,7 @@ package org.oxycblt.auxio
/**
* A table containing all of the magic integer codes that the codebase has currently reserved. May
* be non-contiguous.
*
* @author Alexander Capehart (OxygenCobalt)
*/
object IntegerTable {
@ -35,18 +37,12 @@ object IntegerTable {
const val VIEW_TYPE_BASIC_HEADER = 0xA004
/** SortHeaderViewHolder */
const val VIEW_TYPE_SORT_HEADER = 0xA005
/** AlbumDetailViewHolder */
const val VIEW_TYPE_ALBUM_DETAIL = 0xA006
/** AlbumSongViewHolder */
const val VIEW_TYPE_ALBUM_SONG = 0xA007
/** ArtistDetailViewHolder */
const val VIEW_TYPE_ARTIST_DETAIL = 0xA008
/** ArtistAlbumViewHolder */
const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
/** ArtistSongViewHolder */
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** GenreDetailViewHolder */
const val VIEW_TYPE_GENRE_DETAIL = 0xA00B
/** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00C
/** "Music playback" notification code */

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* MainActivity.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
@ -40,17 +41,13 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* 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)
*
* 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
class MainActivity : AppCompatActivity() {
@ -112,6 +109,7 @@ class MainActivity : AppCompatActivity() {
/**
* Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action] that can be used
* in the playback system.
*
* @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,
* false otherwise.

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* MainFragment.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
@ -51,6 +52,7 @@ import org.oxycblt.auxio.util.*
/**
* A wrapper around the home fragment that shows the playback fragment and controls the more
* high-level navigation features.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
@ -127,12 +129,12 @@ class MainFragment :
}
// --- VIEWMODEL SETUP ---
collect(navModel.mainNavigationAction, ::handleMainNavigation)
collect(navModel.exploreNavigationItem, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem, ::handleArtistNavigationPicker)
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong, ::handlePlaybackGenrePicker)
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
}
override fun onStart() {
@ -268,10 +270,11 @@ class MainFragment :
when (action) {
is MainNavigationAction.Expand -> tryExpandSheets()
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?) {
@ -285,7 +288,7 @@ class MainFragment :
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.actionPickNavigationArtist(item.uid)))
navModel.finishExploreNavigation()
navModel.exploreArtistNavigationItem.consume()
}
}
@ -302,7 +305,7 @@ class MainFragment :
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.actionPickPlaybackArtist(song.uid)))
playbackModel.finishPlaybackArtistPicker()
playbackModel.artistPickerSong.consume()
}
}
@ -311,7 +314,7 @@ class MainFragment :
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.actionPickPlaybackGenre(song.uid)))
playbackModel.finishPlaybackGenrePicker()
playbackModel.genrePickerSong.consume()
}
}

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* AlbumDetailFragment.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
@ -24,16 +25,18 @@ import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearSmoothScroller
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
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.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -47,11 +50,14 @@ import org.oxycblt.auxio.util.*
/**
* A [ListFragment] that shows information about an [Album].
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class AlbumDetailFragment :
ListFragment<Song, FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
ListFragment<Song, FragmentDetailBinding>(),
AlbumDetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Song> {
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel 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
// as a UID, as that is the only safe way to parcel an album.
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?) {
super.onCreate(savedInstanceState)
@ -86,7 +93,7 @@ class AlbumDetailFragment :
setOnMenuItemClickListener(this@AlbumDetailFragment)
}
binding.detailRecycler.adapter = detailAdapter
binding.detailRecycler.adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
// -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
@ -95,7 +102,7 @@ class AlbumDetailFragment :
collectImmediately(detailModel.albumList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
}
@ -103,6 +110,9 @@ class AlbumDetailFragment :
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(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 {
@ -181,14 +191,15 @@ class AlbumDetailFragment :
return
}
requireBinding().detailToolbar.title = album.resolveName(requireContext())
albumHeaderAdapter.setParent(album)
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) {
detailAdapter.setPlaying(song, isPlaying)
albumListAdapter.setPlaying(song, isPlaying)
} else {
// 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) {
logD("Navigating to a song in this album")
scrollToAlbumSong(item)
navModel.finishExploreNavigation()
navModel.exploreNavigationItem.consume()
} else {
logD("Navigating to another album")
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) {
logD("Navigating to the top of this album")
binding.detailRecycler.scrollToPosition(0)
navModel.finishExploreNavigation()
navModel.exploreNavigationItem.consume()
} else {
logD("Navigating to another album")
findNavController()
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.uid))
.navigateSafe(AlbumDetailFragmentDirections.actionShowAlbum(item.uid))
}
}
@ -227,7 +238,7 @@ class AlbumDetailFragment :
is Artist -> {
logD("Navigating to another artist")
findNavController()
.navigate(AlbumDetailFragmentDirections.actionShowArtist(item.uid))
.navigateSafe(AlbumDetailFragmentDirections.actionShowArtist(item.uid))
}
null -> {}
else -> error("Unexpected datatype: ${item::class.java}")
@ -272,12 +283,12 @@ class AlbumDetailFragment :
}
}
private fun updateList(items: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF)
private fun updateList(list: List<Item>) {
albumListAdapter.update(list, detailModel.albumInstructions.consume())
}
private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelected(selected.toSet())
albumListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

View file

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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* DetailAppBarLayout.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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* DetailViewModel.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
@ -29,10 +30,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
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.Item
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.AudioInfo
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
* current item they are showing, sub-data to display, and configuration.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@HiltViewModel
@ -79,6 +82,10 @@ constructor(
/** The current list data derived from [currentAlbum]. */
val albumList: StateFlow<List<Item>>
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]. */
var albumSongSort: Sort
@ -86,7 +93,7 @@ constructor(
set(value) {
musicSettings.albumSongSort = value
// Refresh the album list to reflect the new sort.
currentAlbum.value?.let(::refreshAlbumList)
currentAlbum.value?.let { refreshAlbumList(it, true) }
}
// --- ARTIST ---
@ -99,6 +106,10 @@ constructor(
private val _artistList = MutableStateFlow(listOf<Item>())
/** The current list derived from [currentArtist]. */
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]. */
var artistSongSort: Sort
@ -106,7 +117,7 @@ constructor(
set(value) {
musicSettings.artistSongSort = value
// Refresh the artist list to reflect the new sort.
currentArtist.value?.let(::refreshArtistList)
currentArtist.value?.let { refreshArtistList(it, true) }
}
// --- GENRE ---
@ -119,6 +130,10 @@ constructor(
private val _genreList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentGenre]. */
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]. */
var genreSongSort: Sort
@ -126,7 +141,7 @@ constructor(
set(value) {
musicSettings.genreSongSort = value
// 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
* [songAudioInfo] will be updated to align with the new [Song].
*
* @param uid The UID of the [Song] to load. Must be valid.
*/
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]
* 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.
*/
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]
* 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.
*/
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]
* 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.
*/
fun setGenreUid(uid: Music.UID) {
@ -237,10 +256,6 @@ constructor(
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) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
@ -253,10 +268,17 @@ constructor(
}
}
private fun refreshAlbumList(album: Album) {
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
logD("Refreshing album data")
val data = mutableListOf<Item>(album)
data.add(SortHeader(R.string.lbl_songs))
val list = mutableListOf<Item>()
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
// songs up by disc and then delimit the groups by a disc header.
@ -266,20 +288,21 @@ constructor(
if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) {
data.add(entry.key)
data.addAll(entry.value)
list.add(entry.key)
list.addAll(entry.value)
}
} else {
// 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")
val data = mutableListOf<Item>(artist)
val list = mutableListOf<Item>()
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
val byReleaseGroup =
@ -306,33 +329,48 @@ constructor(
logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
data.add(BasicHeader(entry.key.headerTitleRes))
data.addAll(entry.value)
list.add(BasicHeader(entry.key.headerTitleRes))
list.addAll(entry.value)
}
// 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()) {
logD("Songs present in this artist, adding header")
data.add(SortHeader(R.string.lbl_songs))
data.addAll(artistSongSort.songs(artist.songs))
list.add(SortHeader(R.string.lbl_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")
val data = mutableListOf<Item>(genre)
val list = mutableListOf<Item>()
// Genre is guaranteed to always have artists and songs.
data.add(BasicHeader(R.string.lbl_artists))
data.addAll(genre.artists)
data.add(SortHeader(R.string.lbl_songs))
data.addAll(genreSongSort.songs(genre.songs))
_genreList.value = data
list.add(BasicHeader(R.string.lbl_artists))
list.addAll(genre.artists)
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
}
list.addAll(genreSongSort.songs(genre.songs))
_genreInstructions.put(instructions)
_genreList.value = list
}
/**
* 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
* instance of this enum.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* GenreDetailFragment.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
@ -24,16 +25,18 @@ import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
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.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album
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.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.auxio.util.*
/**
* A [ListFragment] that shows information for a particular [Genre].
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class GenreDetailFragment :
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
ListFragment<Music, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel 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
// as a UID, as that is the only safe way to parcel an genre.
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?) {
super.onCreate(savedInstanceState)
@ -88,7 +91,7 @@ class GenreDetailFragment :
setOnMenuItemClickListener(this@GenreDetailFragment)
}
binding.detailRecycler.adapter = detailAdapter
binding.detailRecycler.adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
@ -97,7 +100,7 @@ class GenreDetailFragment :
collectImmediately(detailModel.genreList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
}
@ -105,6 +108,9 @@ class GenreDetailFragment :
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(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 {
@ -191,8 +197,8 @@ class GenreDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = genre.resolveName(requireContext())
genreHeaderAdapter.setParent(genre)
}
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) {
playingMusic = song
}
detailAdapter.setPlaying(playingMusic, isPlaying)
genreListAdapter.setPlaying(playingMusic, isPlaying)
}
private fun handleNavigation(item: Music?) {
@ -212,31 +218,31 @@ class GenreDetailFragment :
is Song -> {
logD("Navigating to another song")
findNavController()
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.uid))
.navigateSafe(GenreDetailFragmentDirections.actionShowAlbum(item.album.uid))
}
is Album -> {
logD("Navigating to another album")
findNavController()
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.uid))
.navigateSafe(GenreDetailFragmentDirections.actionShowAlbum(item.uid))
}
is Artist -> {
logD("Navigating to another artist")
findNavController()
.navigate(GenreDetailFragmentDirections.actionShowArtist(item.uid))
.navigateSafe(GenreDetailFragmentDirections.actionShowArtist(item.uid))
}
is Genre -> {
navModel.finishExploreNavigation()
navModel.exploreNavigationItem.consume()
}
null -> {}
}
}
private fun updateList(items: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF)
private fun updateList(list: List<Item>) {
genreListAdapter.update(list, detailModel.genreInstructions.consume())
}
private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelected(selected.toSet())
genreListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* ReadOnlyTextInput.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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* SongDetailDialog.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
@ -28,9 +29,9 @@ import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.detail.recycler.SongProperty
import org.oxycblt.auxio.detail.recycler.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.metadata.AudioInfo
@ -42,6 +43,7 @@ import org.oxycblt.auxio.util.concatLocalized
/**
* A [ViewBindingDialogFragment] that shows information about a Song.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
@ -77,7 +79,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
if (info != null) {
val context = requireContext()
detailAdapter.submitList(
detailAdapter.update(
buildList {
add(SongProperty(R.string.lbl_name, song.zipName(context)))
add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
@ -102,7 +104,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
SongProperty(
R.string.lbl_relative_path, song.path.parent.resolveName(context)))
info.resolvedMimeType.resolveName(context)?.let {
SongProperty(R.string.lbl_format, it)
add(SongProperty(R.string.lbl_format, it))
}
add(
SongProperty(
@ -117,7 +119,7 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
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
* AlbumDetailListAdapter.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
@ -15,7 +16,7 @@
* 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.ViewGroup
@ -25,7 +26,6 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.list.Item
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.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.areRawNamesTheSame
import org.oxycblt.auxio.music.metadata.Disc
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
/**
* An [DetailAdapter] implementing the header and sub-items for the [Album] detail view.
* @param listener A [Listener] to bind interactions to.
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
*
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(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()
}
class AlbumDetailListAdapter(private val listener: Listener<Song>) :
DetailListAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Support the Album header, sub-headers for each disc, and special album songs.
is Album -> AlbumDetailViewHolder.VIEW_TYPE
// Support sub-headers for each disc, and special album songs.
is Disc -> DiscViewHolder.VIEW_TYPE
is Song -> AlbumSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
@ -70,7 +56,6 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent)
DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
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) {
super.onBindViewHolder(holder, position)
when (val item = getItem(position)) {
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
is Disc -> (holder as DiscViewHolder).bind(item)
is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
}
@ -98,101 +82,31 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Album && newItem is Album ->
AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Disc && newItem is Disc ->
DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
// 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
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from]
* to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param disc The new [disc] to bind.
*/
fun bind(disc: Disc) {
@ -209,6 +123,7 @@ private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) :
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @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
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param song The new [Song] to bind.
* @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.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* ArtistDetailListAdapter.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
@ -15,15 +16,13 @@
* 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.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.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
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.music.*
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailAdapter] implementing the header and sub-items for the [Artist] detail view.
* @param listener A [DetailAdapter.Listener] to bind interactions to.
* A [DetailListAdapter] implementing the header and sub-items for the [Artist] detail view.
*
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailAdapter(private val listener: Listener<Music>) :
DetailAdapter(listener, DIFF_CALLBACK) {
class ArtistDetailListAdapter(private val listener: Listener<Music>) :
DetailListAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Support an artist header, and special artist albums/songs.
is Artist -> ArtistDetailViewHolder.VIEW_TYPE
// Support a special artist albums/songs.
is Album -> ArtistAlbumViewHolder.VIEW_TYPE
is Song -> ArtistSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
@ -53,7 +51,6 @@ class ArtistDetailAdapter(private val listener: Listener<Music>) :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
ArtistDetailViewHolder.VIEW_TYPE -> ArtistDetailViewHolder.from(parent)
ArtistAlbumViewHolder.VIEW_TYPE -> ArtistAlbumViewHolder.from(parent)
ArtistSongViewHolder.VIEW_TYPE -> ArtistSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
@ -63,7 +60,6 @@ class ArtistDetailAdapter(private val listener: Listener<Music>) :
super.onBindViewHolder(holder, position)
// Re-binding an item with new data and not just a changed selection/playing state.
when (val item = getItem(position)) {
is Artist -> (holder as ArtistDetailViewHolder).bind(item, listener)
is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener)
is Song -> (holder as ArtistSongViewHolder).bind(item, listener)
}
@ -81,106 +77,29 @@ class ArtistDetailAdapter(private val listener: Listener<Music>) :
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Artist && newItem is Artist ->
ArtistDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(
oldItem, newItem)
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Album && newItem is Album ->
ArtistAlbumViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
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
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class ArtistAlbumViewHolder private constructor(private val binding: ItemParentBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param album The new [Album] to bind.
* @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.
*
* @param parent The parent to inflate this instance from.
* @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
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class ArtistSongViewHolder private constructor(private val binding: ItemSongBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param song The new [Song] to bind.
* @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.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* DetailListAdapter.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
@ -15,7 +16,7 @@
* 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.ViewGroup
@ -36,18 +37,18 @@ import org.oxycblt.auxio.util.context
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 diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the
* internal list.
* @param diffCallback A [DiffUtil.ItemCallback] to compare list updates with.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class DetailAdapter(
abstract class DetailListAdapter(
private val listener: Listener<*>,
diffCallback: DiffUtil.ItemCallback<Item>
private val diffCallback: DiffUtil.ItemCallback<Item>
) :
SelectionIndicatorAdapter<Item, BasicListInstructions, RecyclerView.ViewHolder>(
ListDiffer.Async(diffCallback)),
SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback),
AuxioRecyclerView.SpanSizeLookup {
override fun getItemViewType(position: Int) =
@ -78,21 +79,8 @@ abstract class DetailAdapter(
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> {
// 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
* should be opened.
@ -119,6 +107,7 @@ abstract class DetailAdapter(
/**
* A header variation that displays a button to open a sort menu.
*
* @param titleRes The string resource to use as the header title
* @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 button opening a menu for sorting. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @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.headerButton.apply {
// 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.
*
* @param parent The parent to inflate this instance from.
* @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
* SongPropertyAdapter.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
@ -15,7 +16,7 @@
* 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.ViewGroup
@ -23,21 +24,19 @@ import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.BasicListInstructions
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.adapter.*
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
/**
* An adapter for [SongProperty] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class SongPropertyAdapter :
DiffAdapter<SongProperty, BasicListInstructions, SongPropertyViewHolder>(
ListDiffer.Blocking(SongPropertyViewHolder.DIFF_CALLBACK)) {
FlexibleListAdapter<SongProperty, SongPropertyViewHolder>(
SongPropertyViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongPropertyViewHolder.from(parent)
@ -48,6 +47,7 @@ class SongPropertyAdapter :
/**
* A property entry for use in [SongPropertyAdapter].
*
* @param name The contextual title to use for the property.
* @param value The value of the property.
* @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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class SongPropertyViewHolder private constructor(private val binding: ItemSongPropertyBinding) :
@ -69,6 +70,7 @@ class SongPropertyViewHolder private constructor(private val binding: ItemSongPr
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @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
* EdgeFrameLayout.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
@ -27,6 +28,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* A [FrameLayout] that automatically applies bottom insets.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class EdgeFrameLayout

View file

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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* HomeModule.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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* HomeSettings.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
@ -28,6 +29,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* User configuration specific to the home UI.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface HomeSettings : Settings<HomeSettings.Listener> {

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* HomeViewModel.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
@ -24,13 +25,17 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD
/**
* The ViewModel for managing the tab data and lists of the home view.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@HiltViewModel
@ -47,11 +52,19 @@ constructor(
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
val songsList: StateFlow<List<Song>>
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>())
/** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */
val albumsList: StateFlow<List<Album>>
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>())
/**
@ -61,11 +74,19 @@ constructor(
*/
val artistsList: MutableStateFlow<List<Artist>>
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>())
/** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */
val genresList: StateFlow<List<Genre>>
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. */
val playbackMode: MusicMode
@ -82,13 +103,14 @@ constructor(
/** The [MusicMode] of the currently shown [Tab]. */
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
* flag is true, all tabs (and their respective ViewPager2 fragments) will be re-created from
* scratch.
*/
val shouldRecreate: StateFlow<Boolean> = _shouldRecreate
val recreateTabs: Event<Unit>
get() = _shouldRecreate
private val _isFastScrolling = MutableStateFlow(false)
/** 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?) {
if (library != null) {
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.
// Applying the preferred sorting to them.
_songsInstructions.put(UpdateInstructions.Diff)
_songsList.value = musicSettings.songSort.songs(library.songs)
_albumsInstructions.put(UpdateInstructions.Diff)
_albumsLists.value = musicSettings.albumSort.albums(library.albums)
_artistsInstructions.put(UpdateInstructions.Diff)
_artistsList.value =
musicSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) {
@ -120,6 +146,7 @@ constructor(
} else {
library.artists
})
_genresInstructions.put(UpdateInstructions.Diff)
_genresList.value = musicSettings.genreSort.genres(library.genres)
}
}
@ -127,7 +154,7 @@ constructor(
override fun onTabsChanged() {
// Tabs changed, update the current tabs and set up a re-create event.
currentTabModes = makeTabModes()
_shouldRecreate.value = true
_shouldRecreate.put(Unit)
}
override fun onHideCollaboratorsChanged() {
@ -138,6 +165,7 @@ constructor(
/**
* Get the preferred [Sort] for a given [Tab].
*
* @param tabMode The [MusicMode] of the [Tab] desired.
* @return The [Sort] preferred for that [Tab]
*/
@ -151,6 +179,7 @@ constructor(
/**
* 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].
*/
fun setSortForCurrentTab(sort: Sort) {
@ -159,18 +188,22 @@ constructor(
when (_currentTabMode.value) {
MusicMode.SONGS -> {
musicSettings.songSort = sort
_songsInstructions.put(UpdateInstructions.Replace(0))
_songsList.value = sort.songs(_songsList.value)
}
MusicMode.ALBUMS -> {
musicSettings.albumSort = sort
_albumsInstructions.put(UpdateInstructions.Replace(0))
_albumsLists.value = sort.albums(_albumsLists.value)
}
MusicMode.ARTISTS -> {
musicSettings.artistSort = sort
_artistsInstructions.put(UpdateInstructions.Replace(0))
_artistsList.value = sort.artists(_artistsList.value)
}
MusicMode.GENRES -> {
musicSettings.genreSort = sort
_genresInstructions.put(UpdateInstructions.Replace(0))
_genresList.value = sort.genres(_genresList.value)
}
}
@ -178,6 +211,7 @@ constructor(
/**
* Update [currentTabMode] to reflect a new ViewPager2 position
*
* @param pagerPos The new position of the ViewPager2 instance.
*/
fun synchronizeTabPosition(pagerPos: Int) {
@ -185,16 +219,9 @@ constructor(
_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.
*
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/
fun setFastScrolling(isFastScrolling: Boolean) {
@ -204,6 +231,7 @@ constructor(
/**
* 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
* the same way as the configuration.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* FastScrollPopupView.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
@ -40,6 +41,7 @@ import org.oxycblt.auxio.util.isRtl
/**
* A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView
*
* @author Alexander Capehart (OxygenCobalt), Hai Zhang
*/
class FastScrollPopupView

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* FastScrollRecyclerView.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
@ -61,11 +62,10 @@ import org.oxycblt.auxio.util.*
* - Added drag listener
* - 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)
*
* TODO: Add vibration when popup changes
* TODO: Improve support for variably sized items (Re-back with library fast scroller?)
*/
class FastScrollRecyclerView
@JvmOverloads
@ -508,6 +508,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
interface PopupProvider {
/**
* Get text to use in the popup at the specified position.
*
* @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
* at [pos].
@ -519,6 +520,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
interface Listener {
/**
* Called when the fast scrolling state changes.
*
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/
fun onFastScrollingChanged(isFastScrolling: Boolean)

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* AlbumListFragment.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
@ -32,8 +33,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
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.recycler.AlbumViewHolder
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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
@ -75,7 +75,7 @@ class AlbumListFragment :
listener = this@AlbumListFragment
}
collectImmediately(homeModel.albumsList, ::updateList)
collectImmediately(homeModel.albumsList, ::updateAlbums)
collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
}
@ -94,11 +94,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
// 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
is Sort.Mode.ByArtist ->
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
is Sort.Mode.ByArtist -> album.artists[0].sortName?.thumbString
// 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()) }
@ -139,8 +138,8 @@ class AlbumListFragment :
openMusicMenu(anchor, R.menu.menu_album_actions, item)
}
private fun updateList(albums: List<Album>) {
albumAdapter.submitList(albums, BasicListInstructions.REPLACE)
private fun updateAlbums(albums: List<Album>) {
albumAdapter.update(albums, homeModel.albumsInstructions.consume())
}
private fun updateSelection(selection: List<Music>) {
@ -154,11 +153,11 @@ class AlbumListFragment :
/**
* A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder].
*
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class AlbumAdapter(private val listener: SelectableListListener<Album>) :
SelectionIndicatorAdapter<Album, BasicListInstructions, AlbumViewHolder>(
ListDiffer.Blocking(AlbumViewHolder.DIFF_CALLBACK)) {
SelectionIndicatorAdapter<Album, AlbumViewHolder>(AlbumViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumViewHolder.from(parent)

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* ArtistListFragment.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
@ -30,8 +31,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
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.recycler.ArtistViewHolder
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.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.nonZeroOrNull
/**
* A [ListFragment] that shows a list of [Artist]s.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
@ -73,7 +74,7 @@ class ArtistListFragment :
listener = this@ArtistListFragment
}
collectImmediately(homeModel.artistsList, ::updateList)
collectImmediately(homeModel.artistsList, ::updateArtists)
collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
}
@ -92,7 +93,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
// 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
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
@ -117,8 +118,8 @@ class ArtistListFragment :
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
}
private fun updateList(artists: List<Artist>) {
artistAdapter.submitList(artists, BasicListInstructions.REPLACE)
private fun updateArtists(artists: List<Artist>) {
artistAdapter.update(artists, homeModel.artistsInstructions.consume().also { logD(it) })
}
private fun updateSelection(selection: List<Music>) {
@ -132,11 +133,11 @@ class ArtistListFragment :
/**
* A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder].
*
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class ArtistAdapter(private val listener: SelectableListListener<Artist>) :
SelectionIndicatorAdapter<Artist, BasicListInstructions, ArtistViewHolder>(
ListDiffer.Blocking(ArtistViewHolder.DIFF_CALLBACK)) {
SelectionIndicatorAdapter<Artist, ArtistViewHolder>(ArtistViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistViewHolder.from(parent)

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* GenreListFragment.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
@ -30,8 +31,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
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.recycler.GenreViewHolder
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.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
/**
* A [ListFragment] that shows a list of [Genre]s.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
@ -72,7 +73,7 @@ class GenreListFragment :
listener = this@GenreListFragment
}
collectImmediately(homeModel.genresList, ::updateList)
collectImmediately(homeModel.genresList, ::updateGenres)
collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
}
@ -91,7 +92,7 @@ class GenreListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
// 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
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@ -116,8 +117,8 @@ class GenreListFragment :
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
}
private fun updateList(artists: List<Genre>) {
genreAdapter.submitList(artists, BasicListInstructions.REPLACE)
private fun updateGenres(genres: List<Genre>) {
genreAdapter.update(genres, homeModel.genresInstructions.consume().also { logD(it) })
}
private fun updateSelection(selection: List<Music>) {
@ -131,11 +132,11 @@ class GenreListFragment :
/**
* A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder].
*
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class GenreAdapter(private val listener: SelectableListListener<Genre>) :
SelectionIndicatorAdapter<Genre, BasicListInstructions, GenreViewHolder>(
ListDiffer.Blocking(GenreViewHolder.DIFF_CALLBACK)) {
SelectionIndicatorAdapter<Genre, GenreViewHolder>(GenreViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreViewHolder.from(parent)

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* SongListFragment.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
@ -32,8 +33,6 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
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.recycler.SongViewHolder
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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
@ -78,7 +78,7 @@ class SongListFragment :
listener = this@SongListFragment
}
collectImmediately(homeModel.songsList, ::updateList)
collectImmediately(homeModel.songsList, ::updateSongs)
collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(
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.
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
// 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
is Sort.Mode.ByArtist ->
song.album.artists[0].collationKey?.run { sourceString.first().uppercase() }
is Sort.Mode.ByArtist -> song.album.artists[0].sortName?.thumbString
// Album -> Use Album Name
is Sort.Mode.ByAlbum ->
song.album.collationKey?.run { sourceString.first().uppercase() }
is Sort.Mode.ByAlbum -> song.album.sortName?.thumbString
// Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
@ -146,8 +144,8 @@ class SongListFragment :
openMusicMenu(anchor, R.menu.menu_song_actions, item)
}
private fun updateList(songs: List<Song>) {
songAdapter.submitList(songs, BasicListInstructions.REPLACE)
private fun updateSongs(songs: List<Song>) {
songAdapter.update(songs, homeModel.songsInstructions.consume())
}
private fun updateSelection(selection: List<Music>) {
@ -165,11 +163,11 @@ class SongListFragment :
/**
* A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder].
*
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class SongAdapter(private val listener: SelectableListListener<Song>) :
SelectionIndicatorAdapter<Song, BasicListInstructions, SongViewHolder>(
ListDiffer.Blocking(SongViewHolder.DIFF_CALLBACK)) {
SelectionIndicatorAdapter<Song, SongViewHolder>(SongViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongViewHolder.from(parent)

View file

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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* Tab.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
@ -22,18 +23,21 @@ import org.oxycblt.auxio.util.logE
/**
* A representation of a library tab suitable for configuration.
*
* @param mode The type of list in the home view this instance corresponds to.
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class Tab(open val mode: MusicMode) {
/**
* 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.
*/
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.
*
* @param mode The type of list in the home view this instance corresponds to.
*/
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.
*
* @param tabs The array of [Tab]s to convert
* @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.
*
* @param intCode The integer representation of the [Tab]s.
* @return An array of [Tab]s corresponding to the sequence.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* TabAdapter.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
@ -30,6 +31,7 @@ import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
*
* @param listener A [EditableListListener] for tab interactions.
*/
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.
*
* @param newTabs The new array of tabs to show.
*/
fun submitTabs(newTabs: Array<Tab>) {
@ -55,6 +58,7 @@ class TabAdapter(private val listener: EditableListListener<Tab>) :
/**
* Update a specific tab to the given value.
*
* @param at The position of the tab to update.
* @param tab The new tab.
*/
@ -66,6 +70,7 @@ class TabAdapter(private val listener: EditableListListener<Tab>) :
/**
* Swap two tabs with each other.
*
* @param a The position of the first 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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class TabViewHolder private constructor(private val binding: ItemTabBinding) :
DialogRecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param tab The new [Tab] to bind.
* @param listener A [EditableListListener] to bind interactions to.
*/
@ -114,6 +121,7 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* TabCustomizeDialog.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
@ -34,6 +35,7 @@ import org.oxycblt.auxio.util.logD
/**
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* TabDragCallback.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
@ -23,6 +24,7 @@ import androidx.recyclerview.widget.RecyclerView
/**
* An [ItemTouchHelper.Callback] that implements dragging in the [TabAdapter].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() {

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* BitmapProvider.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
@ -55,6 +56,7 @@ constructor(
interface Target {
/**
* Configure the [ImageRequest.Builder] to enable [Target]-specific configuration.
*
* @param builder The [ImageRequest.Builder] that will be used to request the desired
* [Bitmap].
* @return The same [ImageRequest.Builder] in order to easily chain configuration methods.
@ -63,6 +65,7 @@ constructor(
/**
* Called when the loading process is completed.
*
* @param bitmap The loaded bitmap, or null if the bitmap could not be loaded.
*/
fun onCompleted(bitmap: Bitmap?)
@ -77,6 +80,7 @@ constructor(
/**
* Load the Album cover [Bitmap] from a [Song].
*
* @param song The song to load a [Bitmap] of it's album cover from.
* @param target The [Target] to deliver the [Bitmap] to asynchronously.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* CoverMode.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
@ -21,6 +22,7 @@ import org.oxycblt.auxio.IntegerTable
/**
* Represents the options available for album cover loading.
*
* @author Alexander Capehart (OxygenCobalt)
*/
enum class CoverMode {
@ -33,6 +35,7 @@ enum class CoverMode {
/**
* The integer representation of this instance.
*
* @see fromIntCode
*/
val intCode: Int
@ -46,6 +49,7 @@ enum class CoverMode {
companion object {
/**
* Convert a [CoverMode] integer representation into an instance.
*
* @param intCode An integer representation of a [CoverMode]
* @return The corresponding [CoverMode], or null if the [CoverMode] is invalid.
* @see CoverMode.intCode

View file

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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* ImageModule.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
@ -27,11 +28,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher
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
import org.oxycblt.auxio.image.extractor.*
@Module
@InstallIn(SingletonComponent::class)

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* ImageSettings.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
@ -27,6 +28,7 @@ import org.oxycblt.auxio.util.logD
/**
* User configuration specific to image loading.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface ImageSettings : Settings<ImageSettings.Listener> {

View file

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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* StyledImageView.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -48,7 +49,6 @@ import org.oxycblt.auxio.util.getDrawableCompat
/**
* An [AppCompatImageView] with some additional styling, including:
*
* - Tonal background
* - Rounded corners based on user preferences
* - Built-in support for binding image data or using a static icon with the same styling as
@ -97,30 +97,35 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/**
* Bind a [Song]'s album cover to this view, also updating the content description.
*
* @param song The [Song] to bind.
*/
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.
*
* @param album the [Album] to bind.
*/
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.
*
* @param artist the [Artist] to bind.
*/
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.
*
* @param genre the [Genre] to bind.
*/
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.
*
* @param music The music to find.
* @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
@ -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
* [StyledImageView].
*
* @param context [Context] required for initialization.
* @param inner The [Drawable] to wrap.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* Components.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -31,7 +32,6 @@ import javax.inject.Inject
import kotlin.math.min
import okio.buffer
import okio.source
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -41,6 +41,7 @@ import org.oxycblt.auxio.music.Song
/**
* A [Keyer] implementation for [Music] data.
*
* @author Alexander Capehart (OxygenCobalt)
*/
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
* [AlbumFactory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumCoverFetcher
private constructor(
private val context: Context,
private val imageSettings: ImageSettings,
private val extractor: CoverExtractor,
private val album: Album
) : Fetcher {
override suspend fun fetch(): FetchResult? =
Covers.fetch(context, imageSettings, album)?.run {
extractor.extract(album)?.run {
SourceResult(
source = ImageSource(source().buffer(), context),
mimeType = null,
@ -73,74 +75,76 @@ private constructor(
}
/** 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> {
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. */
class AlbumFactory @Inject constructor(private val imageSettings: ImageSettings) :
class AlbumFactory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Album> {
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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistImageFetcher
private constructor(
private val context: Context,
private val imageSettings: ImageSettings,
private val extractor: CoverExtractor,
private val size: Size,
private val artist: Artist
) : Fetcher {
override suspend fun fetch(): FetchResult? {
// 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 results =
albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, imageSettings, album) }
val results = albums.mapAtMostNotNull(4) { album -> extractor.extract(album) }
return Images.createMosaic(context, results, size)
}
/** [Fetcher.Factory] implementation. */
class Factory @Inject constructor(private val imageSettings: ImageSettings) :
class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Artist> {
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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreImageFetcher
private constructor(
private val context: Context,
private val imageSettings: ImageSettings,
private val extractor: CoverExtractor,
private val size: Size,
private val genre: Genre
) : Fetcher {
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)
}
/** [Fetcher.Factory] implementation. */
class Factory @Inject constructor(private val imageSettings: ImageSettings) :
class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Genre> {
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
* transformed into [R].
*
* @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
* the new list. Can return null if the [T] cannot be transformed into an [R].

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
* ErrorCrossfadeTransitionFactory.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
@ -27,6 +28,7 @@ import coil.transition.TransitionTarget
/**
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.
*
* @author Coil Team, Alexander Capehart (OxygenCobalt)
*/
class ErrorCrossfadeTransitionFactory : Transition.Factory {

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* Images.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
@ -37,12 +38,14 @@ import okio.source
/**
* Utilities for constructing Artist and Genre images.
*
* @author Alexander Capehart (OxygenCobalt), Karim Abou Zeid
*/
object Images {
/**
* Create a mosaic image from the given image [InputStream]s. Derived from phonograph:
* https://github.com/kabouzeid/Phonograph
*
* @param context [Context] required to generate the mosaic.
* @param streams [InputStream]s of image data to create the mosaic out of.
* @param size [Size] of the Mosaic to generate.
@ -104,6 +107,7 @@ object Images {
/**
* Get an image dimension suitable to create a mosaic with.
*
* @return A pixel dimension derived from the given [Dimension] that will always be even,
* allowing it to be sub-divided.
*/

View file

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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* Data.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
@ -24,6 +25,7 @@ interface Item
/**
* A "header" used for delimiting groups of data.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface Header : Item {
@ -33,6 +35,7 @@ interface Header : Item {
/**
* A basic header with no additional actions.
*
* @param titleRes The string resource used for the header's title.
* @author Alexander Capehart (OxygenCobalt)
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* ListFragment.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
@ -36,6 +37,7 @@ import org.oxycblt.auxio.util.showToast
/**
* A Fragment containing a selectable list.
*
* @author Alexander Capehart (OxygenCobalt)
*/
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
* less corresponds to an [onClick] implementation in a non-[ListFragment].
*
* @param item The [T] data of the item that was clicked.
*/
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
* 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 menuRes The resource of the menu to load.
* @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
* 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 menuRes The resource of the menu to load.
* @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
* 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 menuRes The resource of the menu to load.
* @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
* 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 menuRes The resource of the menu to load.
* @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.
* If a menu is already opened, this call is ignored.
*
* @param anchor The [View] to anchor the menu to.
* @param menuRes The resource of the menu to load.
* @param block A block that is ran within [PopupMenu] that allows further configuration.

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* Listeners.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
@ -23,11 +24,13 @@ import androidx.recyclerview.widget.RecyclerView
/**
* A basic listener for list interactions.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface ClickableListListener<in T> {
/**
* Called when an item in the list is clicked.
*
* @param item The [T] item that was clicked.
* @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
*/
@ -35,6 +38,7 @@ interface ClickableListListener<in T> {
/**
* Binds this instance to a list item.
*
* @param item The [T] to bind this item to.
* @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
@ -47,17 +51,20 @@ interface ClickableListListener<in T> {
/**
* An extension of [ClickableListListener] that enables list editing functionality.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface EditableListListener<in T> : ClickableListListener<T> {
/**
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
*
* @param viewHolder The [RecyclerView.ViewHolder] that should start being dragged.
*/
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
/**
* Binds this instance to a list item.
*
* @param item The [T] to bind this item to.
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param bodyView The [View] containing the main body of the list item. Any click events on
@ -83,11 +90,13 @@ interface EditableListListener<in T> : ClickableListListener<T> {
/**
* An extension of [ClickableListListener] that enables menu and selection functionality.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface SelectableListListener<in T> : ClickableListListener<T> {
/**
* 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 anchor The [View] to anchor the menu to.
*/
@ -95,12 +104,14 @@ interface SelectableListListener<in T> : ClickableListListener<T> {
/**
* Called when an item in the list requests that it be selected.
*
* @param item The [T] item to select.
*/
fun onSelect(item: T)
/**
* Binds this instance to a list item.
*
* @param item The [T] to bind this item to.
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param bodyView The [View] containing the main body of the list item. Any click events on

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* Sort.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
@ -38,6 +39,7 @@ import org.oxycblt.auxio.music.metadata.Disc
data class Sort(val mode: Mode, val direction: Direction) {
/**
* Create a new [Sort] with the same [mode], but a different [Direction].
*
* @param direction The new [Direction] to sort in.
* @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.
*
* @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.
*/
@ -52,6 +55,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/**
* Sort a list of [Song]s.
*
* @param songs The list of [Song]s.
* @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.
*
* @param albums The list of [Album]s.
* @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.
*
* @param artists The list of [Artist]s.
* @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.
*
* @param genres The list of [Genre]s.
* @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.
*
* @param songs The [Song]s to sort.
*/
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.
*
* @param albums The [Album]s to sort.
*/
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.
*
* @param artists The [Album]s to sort.
*/
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.
*
* @param genres The [Genre]s to sort.
*/
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.
*
* @see fromIntCode
*/
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].
*
* @param direction The direction to sort in.
* @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].
*
* @param direction The direction to sort in.
* @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].
*
* @param direction The direction to sort in.
* @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].
*
* @param direction The direction to sort in.
* @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.
* @see Music.collationKey
*
* @see Music.sortName
*/
object ByName : Mode() {
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.
*
* @see Album.collationKey
*/
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].
* @see Artist.collationKey
*
* @see Artist.sortName
*/
object ByArtist : Mode() {
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].
*
* @see Song.date
* @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.
*
* @see MusicParent.songs
*/
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.
*
* @see Song.disc
*/
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.
*
* @see Song.track
*/
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.
*
* @see Song.dateAdded
* @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].
*
* @param direction The [Direction] to sort in.
* @see compareBy
* @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]
*
* @param direction The [Direction] to sort in.
* @param comparator A [Comparator] to wrap.
* @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]
*
* @param direction The [Direction] to sort in.
* @param comparator A [Comparator] to wrap.
* @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
* given [Comparator], with a selector based on the item itself.
*
* @param comparator The [Comparator] to wrap.
* @return A new [Comparator] with the specified configuration.
* @see compareBy
@ -448,6 +476,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/**
* 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
* during a comparison, with the first non-equal result becoming the result.
*/
@ -468,6 +497,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/**
* Wraps a [Comparator], extending it to compare two lists.
*
* @param inner The [Comparator] to use.
*/
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
* [NullableComparator], however comparing [Music.collationKey] instead of [Comparable].
*
* @see NullableComparator
* @see Music.collationKey
*/
private class BasicComparator<T : Music> private constructor() : Comparator<T> {
override fun compare(a: T, b: T): Int {
val aKey = a.collationKey
val bKey = b.collationKey
val aKey = a.sortName
val bKey = b.sortName
return when {
aKey != null && bKey != null -> aKey.compareTo(bKey)
aKey == null && bKey != null -> -1 // a < b
@ -555,6 +586,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
companion object {
/**
* Convert a [Mode] integer representation into an instance.
*
* @param intCode An integer representation of a [Mode]
* @return The corresponding [Mode], or null if the [Mode] is invalid.
* @see intCode
@ -575,6 +607,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/**
* Convert a menu item ID into a [Mode].
*
* @param itemId The menu resource ID to convert
* @return A [Mode] corresponding to the given ID, or null if the ID is invalid.
* @see itemId
@ -604,6 +637,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
companion object {
/**
* Convert a [Sort] integer representation into an instance.
*
* @param intCode An integer representation of a [Sort]
* @return The corresponding [Sort], or null if the [Sort] is invalid.
* @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
* PlayingIndicatorAdapter.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
@ -18,17 +19,19 @@
package org.oxycblt.auxio.list.adapter
import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.logD
/**
* 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)
*/
abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
differFactory: ListDiffer.Factory<T, I>
) : DiffAdapter<T, I, VH>(differFactory) {
abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
diffCallback: DiffUtil.ItemCallback<T>
) : FlexibleListAdapter<T, VH>(diffCallback) {
// There are actually two states for this adapter:
// - 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
@ -39,7 +42,7 @@ abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
// Only try to update the playing indicator if the ViewHolder supports it
if (holder is ViewHolder) {
holder.updatePlayingIndicator(currentList[position] == currentItem, isPlaying)
holder.updatePlayingIndicator(getItem(position) == currentItem, isPlaying)
}
if (payloads.isEmpty()) {
@ -50,6 +53,7 @@ abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
}
/**
* Update the currently playing item in the list.
*
* @param item The [T] currently being played, or null if it is not being played.
* @param isPlaying Whether playback is ongoing or paused.
*/
@ -103,6 +107,7 @@ abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
abstract class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
/**
* Update the playing indicator within this [RecyclerView.ViewHolder].
*
* @param isActive True if this item is playing, false otherwise.
* @param isPlaying True if playback is ongoing, false if paused. If this is true,
* [isActive] will also be true.

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* SelectionIndicatorAdapter.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
@ -18,18 +19,20 @@
package org.oxycblt.auxio.list.adapter
import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.Music
/**
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
* 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)
*/
abstract class SelectionIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
differFactory: ListDiffer.Factory<T, I>
) : PlayingIndicatorAdapter<T, I, VH>(differFactory) {
abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
diffCallback: DiffUtil.ItemCallback<T>
) : PlayingIndicatorAdapter<T, VH>(diffCallback) {
private var selectedItems = setOf<T>()
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.
*
* @param items A set of selected [T] items.
*/
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.
val added = !oldSelectedItems.contains(item) && newSelectedItems.contains(item)
val removed = oldSelectedItems.contains(item) && !newSelectedItems.contains(item)
if (added || removed) {
if (oldSelectedItems.contains(item) xor newSelectedItems.contains(item)) {
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) {
/**
* Update the selection indicator within this [PlayingIndicatorAdapter.ViewHolder].
*
* @param isSelected Whether this [PlayingIndicatorAdapter.ViewHolder] is selected.
*/
abstract fun updateSelectionIndicator(isSelected: Boolean)

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* SimpleDiffCallback.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
@ -23,6 +24,7 @@ import org.oxycblt.auxio.list.Item
/**
* A [DiffUtil.ItemCallback] that automatically implements the [areItemsTheSame] method. Use this
* whenever creating [DiffUtil.ItemCallback] implementations with an [Item] subclass.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class SimpleDiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {

View file

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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* DialogRecyclerView.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
@ -34,6 +35,7 @@ import org.oxycblt.auxio.util.getDimenPixels
* A [RecyclerView] intended for use in Dialogs, adding features such as:
* - NestedScrollView scrollIndicators behavior emulation
* - Dialog-specific [ViewHolder] that automatically resolves certain issues.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class DialogRecyclerView

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* HeaderItemDecoration.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
@ -19,16 +20,18 @@ package org.oxycblt.auxio.list.recycler
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.BackportMaterialDividerItemDecoration
import org.oxycblt.auxio.R
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
* separate content with headers.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class HeaderItemDecoration
@ -39,12 +42,26 @@ constructor(
defStyleAttr: Int = R.attr.materialDividerStyle,
orientation: Int = LinearLayoutManager.VERTICAL
) : 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 {
// 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
// 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) {
false
} catch (e: IndexOutOfBoundsException) {

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* ViewHolders.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
@ -36,12 +37,14 @@ import org.oxycblt.auxio.util.logD
/**
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class SongViewHolder private constructor(private val binding: ItemSongBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param song The new [Song] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
@ -67,6 +70,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumViewHolder private constructor(private val binding: ItemParentBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param album The new [Album] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
@ -115,6 +121,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param artist The new [Artist] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
@ -173,6 +182,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreViewHolder private constructor(private val binding: ItemParentBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param genre The new [Genre] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
@ -227,6 +239,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param basicHeader The new [BasicHeader] to bind.
*/
fun bind(basicHeader: BasicHeader) {
@ -262,6 +277,7 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* SelectionFragment.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
@ -28,6 +29,7 @@ import org.oxycblt.auxio.util.showToast
/**
* A subset of ListFragment that implements aspects of the selection UI.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class SelectionFragment<VB : ViewBinding> :
@ -38,6 +40,7 @@ abstract class SelectionFragment<VB : ViewBinding> :
/**
* Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed by
* [SelectionFragment].
*
* @return The [SelectionToolbarOverlay] of the concrete [SelectionFragment]'s [VB], or null if
* there is not one.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* SelectionToolbarOverlay.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
@ -32,6 +33,7 @@ import org.oxycblt.auxio.util.logD
/**
* A wrapper around a [MaterialToolbar] that adds an additional [MaterialToolbar] showing the
* current selection state.
*
* @author Alexander Capehart (OxygenCobalt)
*/
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
* pressed.
*
* @param listener The OnClickListener to respond to this interaction.
* @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
* [MaterialToolbar].
*
* @param listener The [OnMenuItemClickListener] to respond to this interaction.
* @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.
*
* @param amount The amount of items that are currently selected.
* @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.
*
* @param selectionVisible Whether the selection [MaterialToolbar] should be visible or not.
* @return true if the toolbars have changed, false otherwise.
*/
@ -152,6 +158,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/**
* Update the alpha of the inner and selection [MaterialToolbar]s.
*
* @param innerAlpha The opacity of the inner [MaterialToolbar]. This will map to the inverse
* opacity of the selection [MaterialToolbar].
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* SelectionViewModel.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
@ -27,6 +28,7 @@ import org.oxycblt.auxio.music.model.Library
/**
* A [ViewModel] that manages the current selection.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@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
* be removed. Otherwise, it will be added.
*
* @param music The [Music] item to select.
*/
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.
*
* @return The list of selected items before it was cleared.
*/
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
* Music.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
@ -22,6 +23,7 @@ import android.net.Uri
import android.os.Parcelable
import java.security.MessageDigest
import java.text.CollationKey
import java.text.Collator
import java.util.UUID
import kotlin.math.max
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
* implementations, such as identification information and names.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface Music : Item {
/**
* A unique identifier for this music item.
*
* @see UID
*/
val uid: UID
@ -56,6 +60,7 @@ sealed interface Music : Item {
/**
* Returns a name suitable for use in the app UI. This should be favored over [rawName] in
* nearly all cases.
*
* @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
* item does not have a name, an analogous "Unknown X" name is returned.
@ -70,26 +75,21 @@ sealed interface Music : Item {
val rawSortName: String?
/**
* A [CollationKey] derived from [rawName] and [rawSortName] that can be used to sort items in a
* semantically-correct manner. Will be null if the item has no name.
*
* 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.
* A black-box value derived from [rawSortName] and [rawName] that can be used for user-friendly
* 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.
*/
val collationKey: CollationKey?
val sortName: SortName?
/**
* A unique identifier for a piece of music.
*
* [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:
*
* - 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]
* allows us to properly differentiate between these in the app.
* have functionally duplicate items that are differentiated with MusicBrainz IDs, and so
* [UID] allows us to properly differentiate between these in the app.
* - 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
* changes, [UID] enables a much stronger form of persistence given it's unique link to a
@ -125,6 +125,7 @@ sealed interface Music : Item {
/**
* Internal marker of [Music.UID] format type.
*
* @param namespace Namespace to use in the [Music.UID]'s string representation.
*/
private enum class Format(val namespace: String) {
@ -139,6 +140,7 @@ sealed interface Music : Item {
/**
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
* unlikely-to-change metadata of the music.
*
* @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
* item. Make sure the metadata hashed semantically aligns with the format
@ -181,6 +183,7 @@ sealed interface Music : Item {
/**
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
* extracted from a file.
*
* @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
* file.
@ -190,6 +193,7 @@ sealed interface Music : Item {
/**
* Convert a [UID]'s string representation back into a concrete [UID] instance.
*
* @param uid The [UID]'s string representation, formatted as
* `format_namespace:music_mode_int-uuid`.
* @return A [UID] converted from the string representation, or null if the string
@ -224,6 +228,7 @@ sealed interface Music : Item {
/**
* An abstract grouping of [Song]s and other [Music] data.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface MusicParent : Music {
@ -233,6 +238,7 @@ sealed interface MusicParent : Music {
/**
* A song.
*
* @author Alexander Capehart (OxygenCobalt)
*/
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
* releases like singles, EPs, and compilations.
*
* @author Alexander Capehart (OxygenCobalt)
*/
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
* within the library, derived from [Song]s and [Album]s respectively.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface Artist : MusicParent {
@ -336,6 +344,7 @@ interface Artist : MusicParent {
/**
* A genre.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface Genre : MusicParent {
@ -347,9 +356,84 @@ interface Genre : MusicParent {
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]
* in a localized manner.
*
* @param context [Context] required
* @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
* display information of an item must be compared without a context.
*
* @param other The list of items to compare to.
* @return True if they are the same (by [Music.rawName]), false otherwise.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* MusicMode.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
@ -21,6 +22,7 @@ import org.oxycblt.auxio.IntegerTable
/**
* Represents a data configuration corresponding to a specific type of [Music],
*
* @author Alexander Capehart (OxygenCobalt)
*/
enum class MusicMode {
@ -35,6 +37,7 @@ enum class MusicMode {
/**
* The integer representation of this instance.
*
* @see fromIntCode
*/
val intCode: Int
@ -49,6 +52,7 @@ enum class MusicMode {
companion object {
/**
* Convert a [MusicMode] integer representation into an instance.
*
* @param intCode An integer representation of a [MusicMode]
* @return The corresponding [MusicMode], or null if the [MusicMode] is invalid.
* @see MusicMode.intCode

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* MusicModule.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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* MusicRepository.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
@ -40,6 +41,7 @@ interface MusicRepository {
/**
* 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.
*
* @param listener The [Listener] to add.
* @see Listener
*/
@ -47,6 +49,7 @@ interface MusicRepository {
/**
* 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
* the first place.
* @see Listener
@ -57,6 +60,7 @@ interface MusicRepository {
interface Listener {
/**
* Called when the current [Library] has changed.
*
* @param library The new [Library], or null if no [Library] has been loaded yet.
*/
fun onLibraryChanged(library: Library?)

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* MusicSettings.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
@ -31,6 +32,7 @@ import org.oxycblt.auxio.util.getSystemServiceCompat
/**
* User configuration specific to music system.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface MusicSettings : Settings<MusicSettings.Listener> {
@ -42,8 +44,9 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
val shouldBeObserving: Boolean
/** A [String] of characters representing the desired characters to denote multi-value tags. */
var multiValueSeparators: String
/** Whether to trim english articles with song sort names. */
val automaticSortNames: Boolean
/** Whether to enable more advanced sorting by articles and numbers. */
val intelligentSorting: Boolean
// TODO: Move sort settings to list module
/** The [Sort] mode used in [Song] lists. */
var songSort: Sort
/** 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)
override var songSort: Sort

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* AudioInfo.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
@ -30,6 +31,7 @@ import org.oxycblt.auxio.util.logW
/**
* The properties of a [Song]'s file.
*
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
* @param sampleRateHz The sample rate, in hertz.
* @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 {
/**
* Extract the [AudioInfo] of a given [Song].
*
* @param song The [Song] to read.
* @return The [AudioInfo] of the [Song], if possible to obtain.
*/
@ -53,6 +56,7 @@ data class AudioInfo(
/**
* A framework-backed implementation of [AudioInfo.Provider].
*
* @param context [Context] required to read audio files.
*/
class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) :

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* Date.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
@ -44,6 +45,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/**
* Resolve this instance into a human-readable date.
*
* @param context [Context] required to get human-readable names.
* @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan
* 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
@ -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
* several sub-items and thus can have a "range" of release dates. Use [from] to create an
* instance.
*
* @author Alexander Capehart
*/
class Range
@ -127,6 +130,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/**
* Resolve this instance into a human-readable date range.
*
* @param context [Context] required to get human-readable names.
* @return If the date has a maximum value, then a `min - max` formatted string will be
* returned with the formatted [Date]s of the minimum and maximum dates respectively.
@ -149,6 +153,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
companion object {
/**
* Create a [Range] from the given list of [Date]s.
*
* @param dates The [Date]s to use.
* @return A [Range] based on the minimum and maximum [Date]s. If there are no [Date]s,
* null is returned.
@ -186,6 +191,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/**
* Create a [Date] from a year component.
*
* @param year The year component.
* @return A new [Date] of the given component, or null if the component is invalid.
*/
@ -204,6 +210,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/**
* Create a [Date] from a date component.
*
* @param year The year component.
* @param month The month component.
* @param day The day component.
@ -214,6 +221,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/**
* Create [Date] from a datetime component.
*
* @param year The year component.
* @param month The month component.
* @param day The day component.
@ -226,10 +234,11 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/**
* Create a [Date] from a [String] timestamp.
*
* @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
* the components were partially invalid, and will be null if all components are invalid or
* if the timestamp is invalid.
* the components were partially invalid, and will be null if all components are invalid
* or if the timestamp is invalid.
*/
fun from(timestamp: String): Date? {
val tokens =
@ -245,6 +254,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
/**
* Create a [Date] from the given non-validated tokens.
*
* @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
* the components were partially invalid, and will be null if all components are invalid.
@ -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
* as soon as an invalid token is found.
*
* @param src The input tokens to validate.
* @param dst The destination list to add valid tokens to.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* Disc.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
@ -21,6 +22,7 @@ import org.oxycblt.auxio.list.Item
/**
* A disc identifier for a song.
*
* @param number The disc number.
* @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
* MetadataModule.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
@ -26,5 +27,6 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class)
interface MetadataModule {
@Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor
@Binds fun tagWorkerFactory(taskFactory: TagWorkerImpl.Factory): TagWorker.Factory
@Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider
}

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* ReleaseType.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
@ -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:
* https://musicbrainz.org/doc/Release_Group/Type
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class ReleaseType {
@ -38,6 +40,7 @@ sealed class ReleaseType {
/**
* A plain album.
*
* @param refinement A specification of what kind of performance this release is. If null, the
* release is considered "Plain".
*/
@ -54,6 +57,7 @@ sealed class ReleaseType {
/**
* 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
* release is considered "Plain".
*/
@ -70,6 +74,7 @@ sealed class ReleaseType {
/**
* 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
* release is considered "Plain".
*/
@ -86,6 +91,7 @@ sealed class ReleaseType {
/**
* 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
* release is considered "Plain".
*/
@ -149,6 +155,7 @@ sealed class ReleaseType {
/**
* Parse a [ReleaseType] from a string formatted with the MusicBrainz Release Group Type
* specification.
*
* @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
* valid.
@ -170,6 +177,7 @@ sealed class ReleaseType {
/**
* Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted with
* the MusicBrainz Release Group Type specification.
*
* @param index The index of the release type to parse.
* @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
@ -194,6 +202,7 @@ sealed class ReleaseType {
/**
* Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to any
* child values.
*
* @param type The release type value to parse.
* @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

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* Separators.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
@ -19,6 +20,7 @@ package org.oxycblt.auxio.music.metadata
/**
* Defines the allowed separator characters that can be used to delimit multi-value tags.
*
* @author Alexander Capehart (OxygenCobalt)
*/
object Separators {

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* SeparatorsDialog.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
@ -33,6 +34,7 @@ import org.oxycblt.auxio.ui.ViewBindingDialogFragment
/**
* A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to
* split tags with multiple values.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* TagExtractor.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
@ -17,20 +18,11 @@
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.source.DefaultMediaSourceFactory
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.AudioOnlyExtractors
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
@ -43,13 +35,14 @@ interface TagExtractor {
/**
* Extract the metadata of songs from [incompleteSongs] and send them to [completeSongs]. Will
* terminate as soon as [incompleteSongs] is closed.
*
* @param incompleteSongs A [Channel] of incomplete songs to process.
* @param completeSongs A [Channel] to send completed songs to.
*/
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 {
override suspend fun consume(
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,
// 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) {
for (i in taskPool.indices) {
val task = taskPool[i]
if (task != null) {
val finishedRawSong = task.get()
if (finishedRawSong != null) {
completeSongs.send(finishedRawSong)
for (i in tagWorkerPool.indices) {
val worker = tagWorkerPool[i]
if (worker != null) {
val completeRawSong = worker.poll()
if (completeRawSong != null) {
completeSongs.send(completeRawSong)
yield()
} else {
continue
}
}
taskPool[i] = Task(context, song)
tagWorkerPool[i] = tagWorkerFactory.create(incompleteRawSong)
break@spin
}
}
@ -80,13 +73,13 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte
do {
var ongoingTasks = false
for (i in taskPool.indices) {
val task = taskPool[i]
for (i in tagWorkerPool.indices) {
val task = tagWorkerPool[i]
if (task != null) {
val finishedRawSong = task.get()
if (finishedRawSong != null) {
completeSongs.send(finishedRawSong)
taskPool[i] = null
val completeRawSong = task.poll()
if (completeRawSong != null) {
completeSongs.send(completeRawSong)
tagWorkerPool[i] = null
yield()
} else {
ongoingTasks = true
@ -102,216 +95,3 @@ class TagExtractorImpl @Inject constructor(@ApplicationContext private val conte
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
* TagUtil.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
@ -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
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the
* user's separator preferences.
*
* @param settings [MusicSettings] required to obtain user separator configuration.
* @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
* the selector.
*
* @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.
*/
@ -83,6 +86,7 @@ inline fun String.splitEscaped(selector: (Char) -> Boolean): List<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
* empty.
*/
@ -90,12 +94,14 @@ fun String.correctWhitespace() = trim().ifBlank { null }
/**
* Fix trailing whitespace or blank contents within a list of [String]s.
*
* @return A list of non-blank strings with trailing whitespace removed.
*/
fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
/**
* Attempt to parse a string by the user's separator preferences.
*
* @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.
*/
@ -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
* (optional) total value delimited by a /.
*
* @return The position value extracted from the string field, or null if:
* - The position could not be parsed
* - The position was zeroed AND the total value was not present/zeroed
*
* @see transformPositionField
*/
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
* and total numbers.
*
* @param pos The position value, or null if not present.
* @param total The total value, if not present.
* @return The position value extracted from the field, or null if:
* - The position could not be parsed
* - The position was zeroed AND the total value was not present/zeroed
*
* @see transformPositionField
*/
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.
*
* @param pos The position value, or null if not present.
* @param total The total value, if not present.
* @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
* representations of genre fields into their named counterparts, and split up singular ID3v2-style
* integer genre fields into one or more genres.
*
* @param settings [MusicSettings] required to obtain user separator configuration.
* @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.
*
* @param settings [MusicSettings] required to obtain user separator configuration.
* @return A list of one or more genre names.
*/
@ -172,6 +185,7 @@ private fun String.parseId3MultiValueGenre(settings: MusicSettings) =
/**
* Parse an ID3v1 integer genre field.
*
* @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.
*/
@ -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
* named/integer genres.
*
* @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>? {

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
* TextTags.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
@ -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.
*
* @param metadata The [Metadata] to wrap.
* @author Alexander Capehart (OxygenCobalt)
*/
@ -79,6 +81,7 @@ class TextTags(metadata: Metadata) {
/**
* 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
* the Unicode replacement byte sequence.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* Library.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
@ -47,6 +48,7 @@ interface Library {
/**
* Finds a [Music] item [T] in the library by it's [Music.UID].
*
* @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
* the [Music.UID] did not correspond to a [T].
@ -55,6 +57,7 @@ interface Library {
/**
* Convert a [Song] from an another library into a [Song] in this [Library].
*
* @param song The [Song] to convert.
* @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].
*
* @param parent The [MusicParent] to convert.
* @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].
*
* @param context [Context] required to analyze the [Uri].
* @param uri [Uri] to search for.
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
@ -78,6 +83,7 @@ interface Library {
companion object {
/**
* Create an instance of [Library].
*
* @param rawSongs [RawSong]s to create the library out of.
* @param settings [MusicSettings] required.
*/
@ -117,6 +123,7 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
/**
* Finds a [Music] item [T] in the library by it's [Music.UID].
*
* @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
* the [Music.UID] did not correspond to a [T].
@ -141,6 +148,7 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
/**
* Build a list [SongImpl]s from the given [RawSong].
*
* @param rawSongs The [RawSong]s to build the [SongImpl]s from.
* @param settings [MusicSettings] to obtain user parsing configuration.
* @return A sorted list of [SongImpl]s derived from the [RawSong] that should be suitable for
@ -152,6 +160,7 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
/**
* Build a list of [Album]s from the given [Song]s.
*
* @param songs The [Song]s to build [Album]s from. These will be linked with their respective
* [Album]s when created.
* @param settings [MusicSettings] to obtain user parsing configuration.
@ -171,6 +180,7 @@ 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
* they group into [Artist] instances much differently, with [Song]s being grouped primarily by
* artist names, and [Album]s being grouped primarily by album artist names.
*
* @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
* created.
@ -210,6 +220,7 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
/**
* Group up [Song]s into [Genre] instances.
*
* @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of
* one or more [Genre] instances. These will be linked with their respective [Genre]s when
* created.

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* MusicImpl.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
@ -20,17 +21,9 @@ package org.oxycblt.auxio.music.model
import android.content.Context
import androidx.annotation.VisibleForTesting
import java.security.MessageDigest
import java.text.CollationKey
import java.text.Collator
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType
@ -46,6 +39,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* Library-backed implementation of [Song].
*
* @param rawSong The [RawSong] to derive the member data from.
* @param musicSettings [MusicSettings] to for user parsing configuration.
* @author Alexander Capehart (OxygenCobalt)
@ -70,7 +64,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
}
override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" }
override val rawSortName = rawSong.sortName
override val collationKey = makeCollationKey(musicSettings)
override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName
override val track = rawSong.track
@ -164,6 +158,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
/**
* Links this [Song] with a parent [Album].
*
* @param album The parent [Album] to link to.
*/
fun link(album: AlbumImpl) {
@ -172,6 +167,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
/**
* Links this [Song] with a parent [Artist].
*
* @param artist The parent [Artist] to link to.
*/
fun link(artist: ArtistImpl) {
@ -180,6 +176,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
/**
* Links this [Song] with a parent [Genre].
*
* @param genre The parent [Genre] to link to.
*/
fun link(genre: GenreImpl) {
@ -188,6 +185,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Song].
*/
fun finalize(): Song {
@ -218,6 +216,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
/**
* Library-backed implementation of [Album].
*
* @param rawAlbum The [RawAlbum] to derive the member data from.
* @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
@ -241,7 +240,7 @@ class AlbumImpl(
}
override val rawName = rawAlbum.name
override val rawSortName = rawAlbum.sortName
override val collationKey = makeCollationKey(musicSettings)
override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName
override val dates = Date.Range.from(songs.mapNotNull { it.date })
@ -286,6 +285,7 @@ class AlbumImpl(
/**
* Links this [Album] with a parent [Artist].
*
* @param artist The parent [Artist] to link to.
*/
fun link(artist: ArtistImpl) {
@ -294,6 +294,7 @@ class AlbumImpl(
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Album].
*/
fun finalize(): Album {
@ -313,11 +314,12 @@ class AlbumImpl(
/**
* Library-backed implementation of [Artist].
*
* @param rawArtist The [RawArtist] to derive the member data from.
* @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
* through artist or album artist tags. Providing [Song]s to the artist is optional. These instances
* will be linked to this [Artist].
* through artist or album artist tags. Providing [Song]s to the artist is optional. These
* instances will be linked to this [Artist].
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistImpl(
@ -331,7 +333,7 @@ class ArtistImpl(
?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) }
override val rawName = rawArtist.name
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 val songs: List<Song>
@ -379,6 +381,7 @@ class ArtistImpl(
* 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
* the original tag order.
*
* @param rawArtists The [RawArtist] instances to check. It is assumed that this [Artist]'s
* [RawArtist] will be within the list.
* @return The index of the [Artist]'s [RawArtist] within the list.
@ -387,6 +390,7 @@ class ArtistImpl(
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Artist].
*/
fun finalize(): Artist {
@ -400,6 +404,7 @@ class ArtistImpl(
}
/**
* Library-backed implementation of [Genre].
*
* @param rawGenre [RawGenre] to derive the member data from.
* @param musicSettings [MusicSettings] to for user parsing configuration.
* @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 rawName = rawGenre.name
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 val albums: List<Album>
@ -450,6 +455,7 @@ class GenreImpl(
* 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
* original tag order.
*
* @param rawGenres The [RawGenre] instances to check. It is assumed that this [Genre] 's
* [RawGenre] will be within the list.
* @return The index of the [Genre]'s [RawGenre] within the list.
@ -458,6 +464,7 @@ class GenreImpl(
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Genre].
*/
fun finalize(): Music {
@ -468,6 +475,7 @@ class GenreImpl(
/**
* Update a [MessageDigest] with a lowercase [String].
*
* @param string The [String] to hash. If null, it will not be hashed.
*/
@VisibleForTesting
@ -481,6 +489,7 @@ fun MessageDigest.update(string: String?) {
/**
* Update a [MessageDigest] with the string representation of a [Date].
*
* @param date The [Date] to hash. If null, nothing will be done.
*/
@VisibleForTesting
@ -494,6 +503,7 @@ fun MessageDigest.update(date: Date?) {
/**
* 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.
*/
@VisibleForTesting
@ -503,6 +513,7 @@ fun MessageDigest.update(strings: List<String?>) {
/**
* Update a [MessageDigest] with the little-endian bytes of a [Int].
*
* @param n The [Int] to write. If null, nothing will be done.
*/
@VisibleForTesting
@ -513,30 +524,3 @@ fun MessageDigest.update(n: Int?) {
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
* RawMusic.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
@ -24,6 +25,7 @@ import org.oxycblt.auxio.music.storage.Directory
/**
* Raw information about a [SongImpl] obtained from the filesystem/Extractor instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class RawSong(
@ -88,6 +90,7 @@ class RawSong(
/**
* Raw information about an [AlbumImpl] obtained from the component [SongImpl] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class RawAlbum(
@ -134,6 +137,7 @@ class RawAlbum(
/**
* Raw information about an [ArtistImpl] obtained from the component [SongImpl] and [AlbumImpl]
* instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class RawArtist(
@ -175,6 +179,7 @@ class RawArtist(
/**
* Raw information about a [GenreImpl] obtained from the component [SongImpl] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class RawGenre(

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* DirectoryAdapter.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
@ -27,6 +28,7 @@ import org.oxycblt.auxio.util.inflater
/**
* [RecyclerView.Adapter] that manages a list of [Directory] instances.
*
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
@ -48,6 +50,7 @@ class DirectoryAdapter(private val listener: Listener) :
/**
* Add a [Directory] to the end of the list.
*
* @param dir The [Directory] to add.
*/
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.
*
* @param dirs The [Directory instances to add.
*/
fun addAll(dirs: List<Directory>) {
@ -71,6 +75,7 @@ class DirectoryAdapter(private val listener: Listener) :
/**
* Remove a [Directory] from the list.
*
* @param dir The [Directory] to remove. Must exist in the list.
*/
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.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
DialogRecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param dir The new [Directory] to bind.
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
*/
@ -104,6 +111,7 @@ class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBi
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* Filesystem.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
@ -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
* preferred in all cases due to scoped storage limitations.
*
* @param name The name of the file.
* @param parent The parent [Directory] of the file.
* @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.
*
* @param volume The [StorageVolume] that the [Directory] is contained in.
* @param relativePath The relative path from within the [StorageVolume] to the [Directory].
* @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) {
/**
* Resolve the [Directory] instance into a human-readable path name.
*
* @param context [Context] required to obtain volume descriptions.
* @return A human-readable path.
* @see StorageVolume.getDescription
@ -55,6 +59,7 @@ 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
* with these uris in the UI, and it doesn't exactly matter since we never write or read to
* directory.
*
* @return A URI [String] abiding by the document tree specification, or null if the [Directory]
* is not valid.
*/
@ -84,6 +89,7 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
/**
* Create a new directory instance from the given components.
*
* @param volume The [StorageVolume] that the [Directory] is contained in.
* @param relativePath The relative path from within the [StorageVolume] to the [Directory].
* Will be stripped of any trailing separators for a consistent internal representation.
@ -97,6 +103,7 @@ 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
* 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.
*
* @param storageManager [StorageManager] in order to obtain the [StorageVolume] specified
* in the given URI.
* @param uri The URI string to parse into a [Directory].
@ -123,6 +130,7 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
/**
* 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 shouldInclude True if the library should only load from the [Directory] instances, false
* if the library should not load from the [Directory] instances.
@ -132,6 +140,7 @@ data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolea
/**
* A mime type of a file. Only intended for display.
*
* @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
* obtained.
@ -140,6 +149,7 @@ data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolea
data class MimeType(val fromExtension: String, val fromFormat: String?) {
/**
* Resolve the mime type into a human-readable format name, such as "Ogg Vorbis".
*
* @param context [Context] required to obtain human-readable strings.
* @return A human-readable name for this mime type. Will first try [fromFormat], then falling
* back to [fromExtension], and then null if that fails.

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