Merge pull request #401 from OxygenCobalt/3.0.4-cherrypick
Version 3.0.4
This commit is contained in:
commit
5b4697410b
248 changed files with 3715 additions and 2071 deletions
14
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
14
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
@ -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
|
||||
|
|
25
CHANGELOG.md
25
CHANGELOG.md
|
@ -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
|
17
README.md
17
README.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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,9 +109,10 @@ 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.
|
||||
* false otherwise.
|
||||
*/
|
||||
private fun startIntentAction(intent: Intent?): Boolean {
|
||||
if (intent == null) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,35 +329,50 @@ 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.
|
||||
* instance of this enum.
|
||||
*/
|
||||
private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
|
||||
ALBUMS(R.string.lbl_albums),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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,88 +82,16 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -187,12 +99,14 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
|
|||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from]
|
||||
* 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.
|
||||
*/
|
|
@ -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,93 +77,14 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -175,12 +92,14 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
|
|||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [from] to
|
||||
* 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.
|
||||
*/
|
|
@ -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.
|
||||
*/
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*/
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,10 +481,11 @@ 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
|
||||
* [FragmentStateAdapter].
|
||||
* [FragmentStateAdapter].
|
||||
*/
|
||||
private class HomePagerAdapter(
|
||||
private val tabs: List<MusicMode>,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,8 +231,9 @@ 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.
|
||||
* the same way as the configuration.
|
||||
*/
|
||||
private fun makeTabModes() =
|
||||
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.mode }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
@ -49,7 +50,7 @@ import org.oxycblt.auxio.util.*
|
|||
*
|
||||
* !!! MODIFICATIONS !!!:
|
||||
* - Scroller will no longer show itself on startup or relayouts, which looked unpleasant with
|
||||
* multiple views
|
||||
* multiple views
|
||||
* - DefaultAnimationHelper and RecyclerViewHelper were merged into the class
|
||||
* - FastScroller overlay was merged into RecyclerView instance
|
||||
* - Removed FastScrollerBuilder
|
||||
|
@ -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,9 +508,10 @@ 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].
|
||||
* at [pos].
|
||||
*/
|
||||
fun getPopup(pos: Int): String?
|
||||
}
|
||||
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,14 +56,16 @@ 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].
|
||||
* [Bitmap].
|
||||
* @return The same [ImageRequest.Builder] in order to easily chain configuration methods.
|
||||
*/
|
||||
fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,11 +49,10 @@ import org.oxycblt.auxio.util.getDrawableCompat
|
|||
|
||||
/**
|
||||
* An [AppCompatImageView] with some additional styling, including:
|
||||
*
|
||||
* - Tonal background
|
||||
* - Rounded corners based on user preferences
|
||||
* - Built-in support for binding image data or using a static icon with the same styling as
|
||||
* placeholder drawables.
|
||||
* placeholder drawables.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -97,34 +97,39 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
/**
|
||||
* Bind a [Song]'s album cover to this view, also updating the content description.
|
||||
*
|
||||
* @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
|
||||
* field for the name of the [Music].
|
||||
* field for the name of the [Music].
|
||||
*/
|
||||
private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
|
||||
val request =
|
||||
|
@ -144,6 +149,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
/**
|
||||
* A [Drawable] wrapper that re-styles the drawable to better align with the style of
|
||||
* [StyledImageView].
|
||||
*
|
||||
* @param context [Context] required for initialization.
|
||||
* @param inner The [Drawable] to wrap.
|
||||
*/
|
||||
|
|
|
@ -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,77 +75,79 @@ 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].
|
||||
* the new list. Can return null if the [T] cannot be transformed into an [R].
|
||||
* @return A new list of at most N non-null [R] items.
|
||||
*/
|
||||
private inline fun <T : Any, R : Any> Collection<T>.mapAtMostNotNull(
|
||||
|
|
|
@ -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) }
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,8 +107,9 @@ 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.
|
||||
* allowing it to be sub-divided.
|
||||
*/
|
||||
private fun Dimension.mosaicSize(): Int {
|
||||
val size = pxOrElse { 512 }
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,10 +38,11 @@ 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
|
||||
* this [View] are routed to the listener. Defaults to the root view.
|
||||
* this [View] are routed to the listener. Defaults to the root view.
|
||||
*/
|
||||
fun bind(item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView) {
|
||||
bodyView.setOnClickListener { onClick(item, viewHolder) }
|
||||
|
@ -47,21 +51,24 @@ 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
|
||||
* this [View] are routed to the listener. Defaults to the root view.
|
||||
* this [View] are routed to the listener. Defaults to the root view.
|
||||
* @param dragHandle A touchable [View]. Any drag on this view will start a drag event.
|
||||
*/
|
||||
fun bind(
|
||||
|
@ -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,16 +104,18 @@ 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
|
||||
* this [View] are routed to the listener. Defaults to the root view.
|
||||
* this [View] are routed to the listener. Defaults to the root view.
|
||||
* @param menuButton A clickable [View]. Any click events on this [View] will open a menu.
|
||||
*/
|
||||
fun bind(
|
||||
|
|
|
@ -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,8 +476,9 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
|
||||
/**
|
||||
* A [Comparator] that chains several other [Comparator]s together to form one comparison.
|
||||
*
|
||||
* @param comparators The [Comparator]s to chain. These will be iterated through in order
|
||||
* during a comparison, with the first non-equal result becoming the result.
|
||||
* during a comparison, with the first non-equal result becoming the result.
|
||||
*/
|
||||
private class MultiComparator<T>(vararg comparators: Comparator<T>) : Comparator<T> {
|
||||
private val _comparators = comparators
|
||||
|
@ -468,6 +497,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
|
||||
/**
|
||||
* Wraps a [Comparator], extending it to compare two lists.
|
||||
*
|
||||
* @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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,9 +107,10 @@ 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.
|
||||
* [isActive] will also be true.
|
||||
*/
|
||||
abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>() {
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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,8 +40,9 @@ 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.
|
||||
* there is not one.
|
||||
*/
|
||||
open fun getSelectionToolbar(binding: VB): SelectionToolbarOverlay? = null
|
||||
|
||||
|
|
|
@ -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,8 +158,9 @@ 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].
|
||||
* opacity of the selection [MaterialToolbar].
|
||||
*/
|
||||
private fun setToolbarsAlpha(innerAlpha: Float) {
|
||||
innerToolbar.apply {
|
||||
|
|
|
@ -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() }
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -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,9 +60,10 @@ 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.
|
||||
* item does not have a name, an analogous "Unknown X" name is returned.
|
||||
*/
|
||||
fun resolveName(context: Context): String
|
||||
|
||||
|
@ -70,31 +75,26 @@ 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
|
||||
* specific files metadata configuration, which is unlikely to collide with another item or
|
||||
* drift as the music library changes.
|
||||
* 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
|
||||
* specific files metadata configuration, which is unlikely to collide with another item or
|
||||
* drift as the music library changes.
|
||||
*
|
||||
* Note: Generally try to use [UID] as a black box that can only be read, written, and compared.
|
||||
* It will not be fun if you try to manipulate it in any other manner.
|
||||
|
@ -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,10 +140,11 @@ 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
|
||||
* specification.
|
||||
* item. Make sure the metadata hashed semantically aligns with the format
|
||||
* specification.
|
||||
* @return A new auxio-style [UID].
|
||||
*/
|
||||
fun auxio(mode: MusicMode, updates: MessageDigest.() -> Unit): UID {
|
||||
|
@ -181,19 +183,21 @@ sealed interface Music : Item {
|
|||
/**
|
||||
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
|
||||
* 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.
|
||||
* file.
|
||||
* @return A new MusicBrainz-style [UID].
|
||||
*/
|
||||
fun musicBrainz(mode: MusicMode, mbid: UUID): UID = UID(Format.MUSICBRAINZ, mode, mbid)
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* `format_namespace:music_mode_int-uuid`.
|
||||
* @return A [UID] converted from the string representation, or null if the string
|
||||
* representation was invalid.
|
||||
* representation was invalid.
|
||||
*/
|
||||
fun fromString(uid: String): UID? {
|
||||
val split = uid.split(':', limit = 2)
|
||||
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,8 +49,9 @@ 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.
|
||||
* the first place.
|
||||
* @see Listener
|
||||
*/
|
||||
fun removeListener(listener: 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?)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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) :
|
||||
|
|
|
@ -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,10 +45,11 @@ 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
|
||||
* be properly localized.
|
||||
* 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will
|
||||
* be properly localized.
|
||||
*/
|
||||
fun resolveDate(context: Context): String {
|
||||
if (month != null) {
|
||||
|
@ -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,10 +130,11 @@ 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.
|
||||
* Otherwise, the formatted name of the minimum [Date] will be returned.
|
||||
* returned with the formatted [Date]s of the minimum and maximum dates respectively.
|
||||
* Otherwise, the formatted name of the minimum [Date] will be returned.
|
||||
*/
|
||||
fun resolveDate(context: Context) =
|
||||
if (min != max) {
|
||||
|
@ -149,9 +153,10 @@ 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.
|
||||
* null is returned.
|
||||
*/
|
||||
fun from(dates: List<Date>): Range? {
|
||||
if (dates.isEmpty()) {
|
||||
|
@ -186,6 +191,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
|||
|
||||
/**
|
||||
* Create a [Date] from a year component.
|
||||
*
|
||||
* @param year The year component.
|
||||
* @return A new [Date] of the given component, or null if the component is invalid.
|
||||
*/
|
||||
|
@ -204,38 +210,41 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
|||
|
||||
/**
|
||||
* Create a [Date] from a date component.
|
||||
*
|
||||
* @param year The year component.
|
||||
* @param month The month component.
|
||||
* @param day The day component.
|
||||
* @return A new [Date] consisting of the given components. May have reduced precision if
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
*/
|
||||
fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day))
|
||||
|
||||
/**
|
||||
* Create [Date] from a datetime component.
|
||||
*
|
||||
* @param year The year component.
|
||||
* @param month The month component.
|
||||
* @param day The day component.
|
||||
* @param hour The hour component
|
||||
* @return A new [Date] consisting of the given components. May have reduced precision if
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
*/
|
||||
fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) =
|
||||
fromTokens(listOf(year, month, day, hour, minute))
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
// Match the input with the timestamp regex. If there is no match, see if we can
|
||||
// fall back to some kind of year value.
|
||||
(ISO8601_REGEX.matchEntire(timestamp)
|
||||
// Match the input with the timestamp regex. If there is no match, see if we can
|
||||
// fall back to some kind of year value.
|
||||
(ISO8601_REGEX.matchEntire(timestamp)
|
||||
?: return timestamp.toIntOrNull()?.let(Companion::from))
|
||||
.groupValues
|
||||
// Filter to the specific tokens we want and convert them to integer tokens.
|
||||
|
@ -245,9 +254,10 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
|||
|
||||
/**
|
||||
* Create a [Date] from the given non-validated tokens.
|
||||
*
|
||||
* @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.
|
||||
* the components were partially invalid, and will be null if all components are invalid.
|
||||
*/
|
||||
private fun fromTokens(tokens: List<Int>): Date? {
|
||||
val validated = mutableListOf<Int>()
|
||||
|
@ -262,6 +272,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
|||
/**
|
||||
* Validate a list of tokens provided by [src], and add the valid ones to [dst]. Will stop
|
||||
* 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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,8 +40,9 @@ 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".
|
||||
* release is considered "Plain".
|
||||
*/
|
||||
data class Album(override val refinement: Refinement?) : ReleaseType() {
|
||||
override val stringRes: Int
|
||||
|
@ -54,8 +57,9 @@ sealed class ReleaseType {
|
|||
|
||||
/**
|
||||
* A "Extended Play", or EP. Usually a smaller release consisting of 4-5 songs.
|
||||
*
|
||||
* @param refinement A specification of what kind of performance this release is. If null, the
|
||||
* release is considered "Plain".
|
||||
* release is considered "Plain".
|
||||
*/
|
||||
data class EP(override val refinement: Refinement?) : ReleaseType() {
|
||||
override val stringRes: Int
|
||||
|
@ -70,8 +74,9 @@ 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".
|
||||
* release is considered "Plain".
|
||||
*/
|
||||
data class Single(override val refinement: Refinement?) : ReleaseType() {
|
||||
override val stringRes: Int
|
||||
|
@ -86,8 +91,9 @@ 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".
|
||||
* release is considered "Plain".
|
||||
*/
|
||||
data class Compilation(override val refinement: Refinement?) : ReleaseType() {
|
||||
override val stringRes: Int
|
||||
|
@ -149,9 +155,10 @@ 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.
|
||||
* valid.
|
||||
*/
|
||||
fun parse(types: List<String>): ReleaseType? {
|
||||
val primary = types.getOrNull(0) ?: return null
|
||||
|
@ -170,10 +177,11 @@ sealed class ReleaseType {
|
|||
/**
|
||||
* Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted with
|
||||
* 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
|
||||
* that are actually [Refinement]s.
|
||||
* corresponding to the callee's context. This is used in order to handle secondary times
|
||||
* that are actually [Refinement]s.
|
||||
* @return A [ReleaseType] corresponding to the secondary type found at that index.
|
||||
*/
|
||||
private inline fun List<String>.parseSecondaryTypes(
|
||||
|
@ -194,10 +202,11 @@ sealed class ReleaseType {
|
|||
/**
|
||||
* Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to any
|
||||
* 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
|
||||
* that are actually [Refinement]s.
|
||||
* corresponding to the callee's context. This is used in order to handle secondary times
|
||||
* that are actually [Refinement]s.
|
||||
*/
|
||||
private inline fun parseSecondaryTypeImpl(
|
||||
type: String?,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,19 +86,22 @@ 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.
|
||||
* empty.
|
||||
*/
|
||||
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,8 +185,9 @@ 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.
|
||||
* "CR"/"RX" respectively, and nothing if the field is not a valid ID3v1 integer genre.
|
||||
*/
|
||||
private fun String.parseId3v1Genre(): String? {
|
||||
// ID3v1 genres are a plain integer value without formatting, so in that case
|
||||
|
@ -200,6 +214,7 @@ private val ID3V2_GENRE_RE = Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?")
|
|||
/**
|
||||
* Parse an ID3v2 integer genre field, which has support for multiple genre values and combined
|
||||
* 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>? {
|
||||
|
|
299
app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt
Normal file
299
app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt
Normal 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")
|
||||
}
|
||||
}
|
|
@ -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,8 +81,9 @@ 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.
|
||||
* the Unicode replacement byte sequence.
|
||||
*/
|
||||
private fun String.sanitize() = String(encodeToByteArray())
|
||||
}
|
||||
|
|
|
@ -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,14 +48,16 @@ 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].
|
||||
* the [Music.UID] did not correspond to a [T].
|
||||
*/
|
||||
fun <T : Music> find(uid: Music.UID): T?
|
||||
|
||||
/**
|
||||
* 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,9 +123,10 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
|
|||
|
||||
/**
|
||||
* Finds a [Music] item [T] in the library by it's [Music.UID].
|
||||
*
|
||||
* @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].
|
||||
* the [Music.UID] did not correspond to a [T].
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST") override fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
|
||||
|
||||
|
@ -130,21 +137,22 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
|
|||
override fun findSongForUri(context: Context, uri: Uri) =
|
||||
context.contentResolverSafe.useQuery(
|
||||
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
|
||||
cursor.moveToFirst()
|
||||
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
|
||||
// song. Do what we can to hopefully find the song the user wanted to open.
|
||||
val displayName =
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
||||
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
|
||||
songs.find { it.path.name == displayName && it.size == size }
|
||||
}
|
||||
cursor.moveToFirst()
|
||||
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
|
||||
// song. Do what we can to hopefully find the song the user wanted to open.
|
||||
val displayName =
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
||||
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
|
||||
songs.find { it.path.name == displayName && it.size == size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list [SongImpl]s from the given [RawSong].
|
||||
*
|
||||
* @param rawSongs The [RawSong]s to build the [SongImpl]s from.
|
||||
* @param settings [MusicSettings] to obtain user parsing configuration.
|
||||
* @return A sorted list of [SongImpl]s derived from the [RawSong] that should be suitable for
|
||||
* grouping.
|
||||
* grouping.
|
||||
*/
|
||||
private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings) =
|
||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
|
@ -152,11 +160,12 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
|
|||
|
||||
/**
|
||||
* Build a list of [Album]s from the given [Song]s.
|
||||
*
|
||||
* @param songs The [Song]s to build [Album]s from. These will be linked with their respective
|
||||
* [Album]s when created.
|
||||
* [Album]s when created.
|
||||
* @param settings [MusicSettings] to obtain user parsing configuration.
|
||||
* @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked
|
||||
* with parent [Artist] instances in order to be usable.
|
||||
* with parent [Artist] instances in order to be usable.
|
||||
*/
|
||||
private fun buildAlbums(songs: List<SongImpl>, settings: MusicSettings): List<AlbumImpl> {
|
||||
// Group songs by their singular raw album, then map the raw instances and their
|
||||
|
@ -171,15 +180,16 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
|
|||
* Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as
|
||||
* 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.
|
||||
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
||||
* created.
|
||||
* @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of
|
||||
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
||||
* created.
|
||||
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
||||
* created.
|
||||
* @param settings [MusicSettings] to obtain user parsing configuration.
|
||||
* @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
|
||||
* of [Song]s and [Album]s.
|
||||
* of [Song]s and [Album]s.
|
||||
*/
|
||||
private fun buildArtists(
|
||||
songs: List<SongImpl>,
|
||||
|
@ -210,9 +220,10 @@ 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.
|
||||
* one or more [Genre] instances. These will be linked with their respective [Genre]s when
|
||||
* created.
|
||||
* @param settings [MusicSettings] to obtain user parsing configuration.
|
||||
* @return A non-empty list of [Genre]s.
|
||||
*/
|
||||
|
|
|
@ -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,14 +39,15 @@ 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)
|
||||
*/
|
||||
class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
|
||||
override val uid =
|
||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) }
|
||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) }
|
||||
?: Music.UID.auxio(MusicMode.SONGS) {
|
||||
// Song UIDs are based on the raw data without parsing so that they remain
|
||||
// consistent across music setting changes. Parents are not held up to the
|
||||
|
@ -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,10 +216,11 @@ 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
|
||||
* [Album].
|
||||
* [Album].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AlbumImpl(
|
||||
|
@ -230,8 +229,8 @@ class AlbumImpl(
|
|||
override val songs: List<SongImpl>
|
||||
) : Album {
|
||||
override val uid =
|
||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) }
|
||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) }
|
||||
?: Music.UID.auxio(MusicMode.ALBUMS) {
|
||||
// Hash based on only names despite the presence of a date to increase stability.
|
||||
// I don't know if there is any situation where an artist will have two albums with
|
||||
|
@ -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(
|
||||
|
@ -326,12 +328,12 @@ class ArtistImpl(
|
|||
songAlbums: List<Music>
|
||||
) : Artist {
|
||||
override val uid =
|
||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
|
||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
|
||||
?: 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,14 +381,16 @@ 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.
|
||||
* [RawArtist] will be within the list.
|
||||
* @return The index of the [Artist]'s [RawArtist] within the list.
|
||||
*/
|
||||
fun getOriginalPositionIn(rawArtists: List<RawArtist>) = rawArtists.indexOf(rawArtist)
|
||||
|
||||
/**
|
||||
* 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,14 +455,16 @@ 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.
|
||||
* [RawGenre] will be within the list.
|
||||
* @return The index of the [Genre]'s [RawGenre] within the list.
|
||||
*/
|
||||
fun getOriginalPositionIn(rawGenres: List<RawGenre>) = rawGenres.indexOf(rawGenre)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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,8 +59,9 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
|
|||
* violation of the document tree URI contract, but it's also the only one can sensibly work
|
||||
* 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.
|
||||
* is not valid.
|
||||
*/
|
||||
fun toDocumentTreeUri() =
|
||||
// Document tree URIs consist of a prefixed volume name followed by a relative path.
|
||||
|
@ -84,9 +89,10 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
|
|||
|
||||
/**
|
||||
* Create a new directory instance from the given components.
|
||||
*
|
||||
* @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.
|
||||
* Will be stripped of any trailing separators for a consistent internal representation.
|
||||
* @return A new [Directory] created from the components.
|
||||
*/
|
||||
fun from(volume: StorageVolume, relativePath: String) =
|
||||
|
@ -97,8 +103,9 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
|
|||
* Create a new directory from a document tree URI. This is a huge violation of the document
|
||||
* 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.
|
||||
* in the given URI.
|
||||
* @param uri The URI string to parse into a [Directory].
|
||||
* @return A new [Directory] parsed from the URI, or null if the URI is not valid.
|
||||
*/
|
||||
|
@ -123,26 +130,29 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
|
|||
|
||||
/**
|
||||
* Represents the configuration for specific directories to filter to/from when loading music.
|
||||
*
|
||||
* @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.
|
||||
* if the library should not load from the [Directory] instances.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolean)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* obtained.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
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.
|
||||
* back to [fromExtension], and then null if that fails.
|
||||
*/
|
||||
fun resolveName(context: Context): String? {
|
||||
// We try our best to produce a more readable name for the common audio formats.
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue