Merge pull request #450 from OxygenCobalt/dev

Version 3.1.0
This commit is contained in:
Alexander Capehart 2023-05-23 02:03:31 +00:00 committed by GitHub
commit 9739e017f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
262 changed files with 8004 additions and 3624 deletions

View file

@ -15,10 +15,10 @@ jobs:
uses: actions/checkout@v3
- name: Clone submodules
run: git submodule update --init --recursive
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '11'
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew

7
.gitmodules vendored
View file

@ -1,4 +1,3 @@
[submodule "ExoPlayer"]
path = ExoPlayer
url = https://github.com/OxygenCobalt/ExoPlayer.git
branch = auxio
[submodule "media"]
path = media
url = https://github.com/OxygenCobalt/media.git

View file

@ -1,5 +1,27 @@
# Changelog
## 3.1.0
#### What's New
- Added playlist functionality
#### What's Improved
- Sorting now handles numbers of arbitrary length
- Punctuation is now ignored in sorting with intelligent sort names disabled
#### What's Fixed
- Fixed issue where vorbis comments in the form of `metadata_block_picture` (lowercase) would not
be parsed as images
- Fixed issue where searches would match song file names case-sensitively
- Fixed issue where the notification would not respond to changes in the album cover setting
- Fixed issue where short names starting with an article would not be correctly sorted (ex. "the 1")
- Fixed incorrect item arrangement on landscape
- Fixed disappearing dividers in search view
- Reduced likelihood that images (eg. album covers) would not update when the music library changed
#### Dev/Meta
- Switched to androidx media3 (New Home of ExoPlayer) for backing player components
## 3.0.5
#### What's Fixed
@ -11,23 +33,23 @@ screen
## 3.0.4
#### What's New
- Added support for `COMPILATION` and `ITUNESCOMPILATION` flags.
- 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
- Searching now ignores punctuation and trailing whitespace
- 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
- Fix visual bugs when editing duplicate songs in the queue
#### What's Changed
- "Ignore articles when sorting" is now "Intelligent sorting"

@ -1 +0,0 @@
Subproject commit fef2bb3af622f235d98549ffe2efd8f7f7d2aa41

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4>
<p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.5">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.5&color=64B5F6&style=flat">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.1.0">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.1.0&color=64B5F6&style=flat">
</a>
<a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
@ -21,7 +21,7 @@
## About
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of <a href="https://exoplayer.dev/">Exoplayer</a>, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of [ExoPlayer](https://exoplayer.dev/), Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
I primarily built Auxio for myself, but you can use it too, I guess.
@ -42,7 +42,7 @@ I primarily built Auxio for myself, but you can use it too, I guess.
## Features
- [ExoPlayer](https://exoplayer.dev/) based playback
- [ExoPlayer](https://exoplayer.dev/)-based playback
- Snappy UI derived from the latest Material Design guidelines
- Opinionated UX that prioritizes ease of use over edge cases
- Customizable behavior
@ -50,7 +50,8 @@ I primarily built Auxio for myself, but you can use it too, I guess.
precise/original dates, sort tags, and more
- Advanced artist system that unifies artists and album artists
- SD Card-aware folder management
- Reliable playback state persistence
- Reliable playlisting functionality
- Playback state persistence
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
- External equalizer support (ex. Wavelet)
- Edge-to-edge

View file

@ -20,8 +20,8 @@ android {
defaultConfig {
applicationId namespace
versionName "3.0.5"
versionCode 29
versionName "3.1.0"
versionCode 30
minSdk 21
targetSdk 33
@ -30,12 +30,12 @@ android {
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "17"
freeCompilerArgs += "-Xjvm-default=all"
}
@ -56,15 +56,16 @@ android {
}
}
}
packagingOptions {
exclude "DebugProbesKt.bin"
exclude "kotlin-tooling-metadata.json"
exclude "**/kotlin/**"
exclude "**/okhttp3/**"
exclude "META-INF/*.version"
jniLibs {
excludes += ['**/kotlin/**', '**/okhttp3/**']
}
resources {
excludes += ['DebugProbesKt.bin', 'kotlin-tooling-metadata.json', '**/kotlin/**', '**/okhttp3/**', 'META-INF/*.version']
}
}
buildFeatures {
viewBinding true
}
@ -75,26 +76,27 @@ 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-guava:1.6.4"
def coroutines_version = '1.7.1'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
// --- SUPPORT ---
// General
// 1.4.0 is used in order to avoid a ripple bug in material components
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.core:core-ktx:1.9.0"
implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.5"
implementation "androidx.core:core-ktx:1.10.1"
implementation "androidx.activity:activity-ktx:1.7.1"
implementation "androidx.fragment:fragment-ktx:1.5.7"
// UI
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:1.9.0'
implementation 'androidx.core:core-ktx:1.10.1'
// Lifecycle
def lifecycle_version = "2.6.0"
def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@ -111,7 +113,7 @@ dependencies {
implementation "androidx.preference:preference-ktx:1.2.0"
// Database
def room_version = '2.5.0'
def room_version = '2.6.0-alpha01'
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
@ -119,27 +121,27 @@ dependencies {
// --- THIRD PARTY ---
// Exoplayer (Vendored)
implementation project(":exoplayer-library-core")
implementation project(":exoplayer-extension-ffmpeg")
implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg")
// Image loading
implementation 'io.coil-kt:coil-base:2.2.2'
implementation 'io.coil-kt:coil-base:2.3.0'
// Material
// TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout can be worked around
// TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout is actually available
// in a version that I can build with
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
// PR a fix.
implementation "com.google.android.material:material:1.8.0-alpha01"
// Dependency Injection
def dagger_version = '2.45'
implementation "com.google.dagger:dagger:$dagger_version"
kapt "com.google.dagger:dagger-compiler:$dagger_version"
implementation "com.google.dagger:dagger:$hilt_version"
kapt "com.google.dagger:dagger-compiler:$hilt_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'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
testImplementation "junit:junit:4.13.2"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

View file

@ -23,3 +23,14 @@
# Obsfucation is what proprietary software does to keep the user unaware of it's abuses.
# Also it's easier to fix issues if the stack trace symbols remain unmangled.
-dontobfuscate
# Make AGP shut up about classes that aren't even used.
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE

View file

@ -35,6 +35,6 @@ class StubTest {
@Test
fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("org.oxycblt.auxio", appContext.packageName)
assertEquals("org.oxycblt.auxio.debug", appContext.packageName)
}
}

View file

@ -25,6 +25,7 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.ui.UISettings
@ -39,6 +40,7 @@ class Auxio : Application() {
@Inject lateinit var imageSettings: ImageSettings
@Inject lateinit var playbackSettings: PlaybackSettings
@Inject lateinit var uiSettings: UISettings
@Inject lateinit var homeSettings: HomeSettings
override fun onCreate() {
super.onCreate()
@ -46,6 +48,7 @@ class Auxio : Application() {
imageSettings.migrate()
playbackSettings.migrate()
uiSettings.migrate()
homeSettings.migrate()
// Adding static shortcuts in a dynamic manner is better than declaring them
// manually, as it will properly handle the difference between debug and release
// Auxio instances.

View file

@ -33,18 +33,26 @@ object IntegerTable {
const val VIEW_TYPE_ARTIST = 0xA002
/** GenreViewHolder */
const val VIEW_TYPE_GENRE = 0xA003
/** PlaylistViewHolder */
const val VIEW_TYPE_PLAYLIST = 0xA004
/** BasicHeaderViewHolder */
const val VIEW_TYPE_BASIC_HEADER = 0xA004
const val VIEW_TYPE_BASIC_HEADER = 0xA005
/** DividerViewHolder */
const val VIEW_TYPE_DIVIDER = 0xA006
/** SortHeaderViewHolder */
const val VIEW_TYPE_SORT_HEADER = 0xA005
const val VIEW_TYPE_SORT_HEADER = 0xA007
/** AlbumSongViewHolder */
const val VIEW_TYPE_ALBUM_SONG = 0xA007
const val VIEW_TYPE_ALBUM_SONG = 0xA008
/** ArtistAlbumViewHolder */
const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
/** ArtistSongViewHolder */
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00C
const val VIEW_TYPE_DISC_HEADER = 0xA00B
/** EditHeaderViewHolder */
const val VIEW_TYPE_EDIT_HEADER = 0xA00C
/** PlaylistSongViewHolder */
const val VIEW_TYPE_PLAYLIST_SONG = 0xA00E
/** "Music playback" notification code */
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** "Music loading" notification code */
@ -65,34 +73,36 @@ object IntegerTable {
const val PLAYBACK_MODE_IN_ALBUM = 0xA105
/** PlaybackMode.ALL_SONGS */
const val PLAYBACK_MODE_ALL_SONGS = 0xA106
/** DisplayMode.NONE (No Longer used but still reserved) */
// const val DISPLAY_MODE_NONE = 0xA107
/** MusicMode._GENRES */
const val MUSIC_MODE_GENRES = 0xA108
/** MusicMode._ARTISTS */
const val MUSIC_MODE_ARTISTS = 0xA109
/** MusicMode._ALBUMS */
const val MUSIC_MODE_ALBUMS = 0xA10A
/** MusicMode.SONGS */
const val MUSIC_MODE_SONGS = 0xA10B
/** Sort.ByName */
/** MusicMode.ALBUMS */
const val MUSIC_MODE_ALBUMS = 0xA10A
/** MusicMode.ARTISTS */
const val MUSIC_MODE_ARTISTS = 0xA109
/** MusicMode.GENRES */
const val MUSIC_MODE_GENRES = 0xA108
/** MusicMode.PLAYLISTS */
const val MUSIC_MODE_PLAYLISTS = 0xA107
/** Sort.Mode.ByName */
const val SORT_BY_NAME = 0xA10C
/** Sort.ByArtist */
/** Sort.Mode.ByArtist */
const val SORT_BY_ARTIST = 0xA10D
/** Sort.ByAlbum */
/** Sort.Mode.ByAlbum */
const val SORT_BY_ALBUM = 0xA10E
/** Sort.ByYear */
/** Sort.Mode.ByYear */
const val SORT_BY_YEAR = 0xA10F
/** Sort.ByDuration */
/** Sort.Mode.ByDuration */
const val SORT_BY_DURATION = 0xA114
/** Sort.ByCount */
/** Sort.Mode.ByCount */
const val SORT_BY_COUNT = 0xA115
/** Sort.ByDisc */
/** Sort.Mode.ByDisc */
const val SORT_BY_DISC = 0xA116
/** Sort.ByTrack */
/** Sort.Mode.ByTrack */
const val SORT_BY_TRACK = 0xA117
/** Sort.ByDateAdded */
/** Sort.Mode.ByDateAdded */
const val SORT_BY_DATE_ADDED = 0xA118
/** Sort.Mode.None */
const val SORT_BY_NONE = 0xA11F
/** ReplayGainMode.Off (No longer used but still reserved) */
// const val REPLAY_GAIN_MODE_OFF = 0xA110
/** ReplayGainMode.Track */

View file

@ -48,6 +48,10 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
* TODO: Migrate to material animation system
* TODO: Unit testing
* TODO: Fix UID naming
* TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims)
* TODO: Add more logging
* TODO: Try to move on from synchronized and volatile in shared objs
*/
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

View file

@ -38,14 +38,17 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.MainNavigationAction
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.*
@ -60,9 +63,11 @@ class MainFragment :
ViewBindingFragment<FragmentMainBinding>(),
ViewTreeObserver.OnPreDrawListener,
NavController.OnDestinationChangedListener {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by activityViewModels()
private val selectionModel: SelectionViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback()
private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f
@ -132,6 +137,10 @@ class MainFragment :
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist)
collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist)
collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
@ -258,7 +267,7 @@ class MainFragment :
initialNavDestinationChange = true
return
}
selectionModel.consume()
selectionModel.drop()
}
private fun handleMainNavigation(action: MainNavigationAction?) {
@ -268,8 +277,8 @@ class MainFragment :
}
when (action) {
is MainNavigationAction.Expand -> tryExpandSheets()
is MainNavigationAction.Collapse -> tryCollapseSheets()
is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel()
is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel()
is MainNavigationAction.Directions ->
findNavController().navigateSafe(action.directions)
}
@ -279,7 +288,7 @@ class MainFragment :
private fun handleExploreNavigation(item: Music?) {
if (item != null) {
tryCollapseSheets()
tryClosePlaybackPanel()
}
}
@ -300,6 +309,40 @@ class MainFragment :
}
}
private fun handleNewPlaylist(songs: List<Song>?) {
if (songs != null) {
findNavController()
.navigateSafe(
MainFragmentDirections.actionNewPlaylist(songs.map { it.uid }.toTypedArray()))
musicModel.newPlaylistSongs.consume()
}
}
private fun handleRenamePlaylist(playlist: Playlist?) {
if (playlist != null) {
findNavController()
.navigateSafe(MainFragmentDirections.actionRenamePlaylist(playlist.uid))
musicModel.playlistToRename.consume()
}
}
private fun handleDeletePlaylist(playlist: Playlist?) {
if (playlist != null) {
findNavController()
.navigateSafe(MainFragmentDirections.actionDeletePlaylist(playlist.uid))
musicModel.playlistToDelete.consume()
}
}
private fun handleAddToPlaylist(songs: List<Song>?) {
if (songs != null) {
findNavController()
.navigateSafe(
MainFragmentDirections.actionAddToPlaylist(songs.map { it.uid }.toTypedArray()))
musicModel.songsToAdd.consume()
}
}
private fun handlePlaybackArtistPicker(song: Song?) {
if (song != null) {
navModel.mainNavigateTo(
@ -318,22 +361,33 @@ class MainFragment :
}
}
private fun tryExpandSheets() {
private fun tryOpenPlaybackPanel() {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is not expanded and not hidden, we can expand it.
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
return
}
val queueSheetBehavior =
(binding.queueSheet.coordinatorLayoutBehavior ?: return) as QueueBottomSheetBehavior
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Queue sheet and playback sheet is expanded, close the queue sheet so the
// playback panel can eb shown.
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
}
}
private fun tryCollapseSheets() {
private fun tryClosePlaybackPanel() {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Make sure the queue is also collapsed here.
// Playback sheet (and possibly queue) needs to be collapsed.
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
@ -406,8 +460,13 @@ class MainFragment :
return
}
// Clear out pending playlist edits.
if (detailModel.dropPlaylistEdit()) {
return
}
// Clear out any prior selections.
if (selectionModel.consume().isNotEmpty()) {
if (selectionModel.drop()) {
return
}
@ -435,6 +494,7 @@ class MainFragment :
isEnabled =
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
detailModel.editedPlaylist.value != null ||
selectionModel.selected.value.isNotEmpty() ||
exploreNavController.currentDestination?.id !=
exploreNavController.graph.startDestinationId

View file

@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
@ -34,6 +35,8 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding
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.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
@ -43,9 +46,11 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
/**
@ -61,6 +66,7 @@ class AlbumDetailFragment :
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
// 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.
@ -87,17 +93,27 @@ class AlbumDetailFragment :
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --
binding.detailToolbar.apply {
binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_album_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@AlbumDetailFragment)
}
binding.detailRecycler.adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
binding.detailRecycler.apply {
adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item = detailModel.albumList.value[it - 1]
item is Divider || item is Header || item is Disc
} else {
true
}
}
}
// -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setAlbumUid(args.albumUid)
detailModel.setAlbum(args.albumUid)
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
collectImmediately(detailModel.albumList, ::updateList)
collectImmediately(
@ -108,7 +124,7 @@ class AlbumDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailNormalToolbar.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.
@ -136,6 +152,10 @@ class AlbumDetailFragment :
onNavigateToParentArtist()
true
}
R.id.action_playlist_add -> {
musicModel.addToPlaylist(currentAlbum)
true
}
else -> false
}
}
@ -159,8 +179,10 @@ class AlbumDetailFragment :
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_album_sort) {
// Select the corresponding sort mode option
val sort = detailModel.albumSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
// Select the corresponding sort direction option
val directionItemId =
when (sort.direction) {
Sort.Direction.ASCENDING -> R.id.option_sort_asc
@ -171,8 +193,10 @@ class AlbumDetailFragment :
item.isChecked = !item.isChecked
detailModel.albumSongSort =
when (item.itemId) {
// Sort direction options
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
// Any other option is a sort mode
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
true
@ -190,7 +214,7 @@ class AlbumDetailFragment :
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = album.resolveName(requireContext())
requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
albumHeaderAdapter.setParent(album)
}
@ -289,6 +313,13 @@ class AlbumDetailFragment :
private fun updateSelection(selected: List<Music>) {
albumListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
} else {
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
}
}

View file

@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
@ -34,6 +35,8 @@ 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.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
@ -42,9 +45,10 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
/**
@ -60,6 +64,7 @@ class ArtistDetailFragment :
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
// 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.
@ -86,18 +91,31 @@ class ArtistDetailFragment :
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply {
inflateMenu(R.menu.menu_genre_artist_detail)
binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@ArtistDetailFragment)
}
binding.detailRecycler.adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
binding.detailRecycler.apply {
adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.artistList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setArtistUid(args.artistUid)
collectImmediately(detailModel.currentArtist, ::updateItem)
detailModel.setArtist(args.artistUid)
collectImmediately(detailModel.currentArtist, ::updateArtist)
collectImmediately(detailModel.artistList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -107,7 +125,7 @@ class ArtistDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailNormalToolbar.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.
@ -131,6 +149,10 @@ class ArtistDetailFragment :
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_playlist_add -> {
musicModel.addToPlaylist(currentArtist)
true
}
else -> false
}
}
@ -171,8 +193,10 @@ class ArtistDetailFragment :
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_artist_sort) {
// Select the corresponding sort mode option
val sort = detailModel.artistSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
// Select the corresponding sort direction option
val directionItemId =
when (sort.direction) {
Sort.Direction.ASCENDING -> R.id.option_sort_asc
@ -184,8 +208,10 @@ class ArtistDetailFragment :
detailModel.artistSongSort =
when (item.itemId) {
// Sort direction options
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
// Any other option is a sort mode
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
@ -194,13 +220,13 @@ class ArtistDetailFragment :
}
}
private fun updateItem(artist: Artist?) {
private fun updateArtist(artist: Artist?) {
if (artist == null) {
// Artist we were showing no longer exists.
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = artist.resolveName(requireContext())
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
artistHeaderAdapter.setParent(artist)
}
@ -260,6 +286,13 @@ class ArtistDetailFragment :
private fun updateSelection(selected: List<Music>) {
artistListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
} else {
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
}
}

View file

@ -44,9 +44,6 @@ import org.oxycblt.auxio.util.lazyReflectedField
* and thus scrolling past them should make the toolbar show the name in order to give context on
* where the user currently is.
*
* This task should nominally be accomplished with CollapsingToolbarLayout, but I have not figured
* out how to get that working sensibly yet.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class DetailAppBarLayout
@ -72,7 +69,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
// used within the detail layouts.
val toolbar = findViewById<Toolbar>(R.id.detail_toolbar)
val toolbar = findViewById<Toolbar>(R.id.detail_normal_toolbar)
// The Toolbar's title view is actually hidden. To avoid having to create our own
// title view, we just reflect into Toolbar and grab the hidden field.

View file

@ -30,16 +30,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.EditHeader
import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
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
import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.*
@ -54,22 +55,22 @@ class DetailViewModel
@Inject
constructor(
private val musicRepository: MusicRepository,
private val audioInfoProvider: AudioInfo.Provider,
private val audioPropertiesFactory: AudioProperties.Factory,
private val musicSettings: MusicSettings,
private val playbackSettings: PlaybackSettings
) : ViewModel(), MusicRepository.Listener {
private var currentSongJob: Job? = null
) : ViewModel(), MusicRepository.UpdateListener {
// --- SONG ---
private var currentSongJob: Job? = null
private val _currentSong = MutableStateFlow<Song?>(null)
/** The current [Song] to display. Null if there is nothing to show. */
val currentSong: StateFlow<Song?>
get() = _currentSong
private val _songAudioInfo = MutableStateFlow<AudioInfo?>(null)
/** The [AudioInfo] of the currently shown [Song]. Null if not loaded yet. */
val songAudioInfo: StateFlow<AudioInfo?> = _songAudioInfo
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
// --- ALBUM ---
@ -144,6 +145,29 @@ constructor(
currentGenre.value?.let { refreshGenreList(it, true) }
}
// --- PLAYLIST ---
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?>
get() = _currentPlaylist
private val _playlistList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentPlaylist] */
val playlistList: StateFlow<List<Item>> = _playlistList
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [playlistList] in the UI. */
val playlistInstructions: Event<UpdateInstructions>
get() = _playlistInstructions
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
/**
* The new playlist songs created during the current editing session. Null if no editing session
* is occurring.
*/
val editedPlaylist: StateFlow<List<Song>?>
get() = _editedPlaylist
/**
* The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently
* shown item.
@ -152,126 +176,218 @@ constructor(
get() = playbackSettings.inParentPlaybackMode
init {
musicRepository.addListener(this)
musicRepository.addUpdateListener(this)
}
override fun onCleared() {
musicRepository.removeListener(this)
musicRepository.removeUpdateListener(this)
}
override fun onLibraryChanged(library: Library?) {
if (library == null) {
// Nothing to do.
return
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
// If we are showing any item right now, we will need to refresh it (and any information
// related to it) with the new library in order to prevent stale items from showing up
// in the UI.
val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) {
val song = currentSong.value
if (song != null) {
_currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo)
logD("Updated song to ${currentSong.value}")
}
val song = currentSong.value
if (song != null) {
_currentSong.value = library.sanitize(song)?.also(::refreshAudioInfo)
logD("Updated song to ${currentSong.value}")
val album = currentAlbum.value
if (album != null) {
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
logD("Updated album to ${currentAlbum.value}")
}
val artist = currentArtist.value
if (artist != null) {
_currentArtist.value =
deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
logD("Updated artist to ${currentArtist.value}")
}
val genre = currentGenre.value
if (genre != null) {
_currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList)
logD("Updated genre to ${currentGenre.value}")
}
}
val album = currentAlbum.value
if (album != null) {
_currentAlbum.value = library.sanitize(album)?.also(::refreshAlbumList)
logD("Updated genre to ${currentAlbum.value}")
}
val artist = currentArtist.value
if (artist != null) {
_currentArtist.value = library.sanitize(artist)?.also(::refreshArtistList)
logD("Updated genre to ${currentArtist.value}")
}
val genre = currentGenre.value
if (genre != null) {
_currentGenre.value = library.sanitize(genre)?.also(::refreshGenreList)
logD("Updated genre to ${currentGenre.value}")
val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) {
val playlist = currentPlaylist.value
if (playlist != null) {
logD("Updated playlist to ${currentPlaylist.value}")
_currentPlaylist.value =
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
}
}
}
/**
* 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].
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
* be updated to align with the new [Song].
*
* @param uid The UID of the [Song] to load. Must be valid.
*/
fun setSongUid(uid: Music.UID) {
if (_currentSong.value?.uid == uid) {
// Nothing to do.
return
}
fun setSong(uid: Music.UID) {
logD("Opening Song [uid: $uid]")
_currentSong.value = requireMusic<Song>(uid)?.also(::refreshAudioInfo)
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
}
/**
* 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].
* Set a new [currentAlbum] from it's [Music.UID]. [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) {
if (_currentAlbum.value?.uid == uid) {
// Nothing to do.
return
}
fun setAlbum(uid: Music.UID) {
logD("Opening Album [uid: $uid]")
_currentAlbum.value = requireMusic<Album>(uid)?.also(::refreshAlbumList)
_currentAlbum.value =
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
}
/**
* 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].
* Set a new [currentArtist] from it's [Music.UID]. [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) {
if (_currentArtist.value?.uid == uid) {
// Nothing to do.
return
}
fun setArtist(uid: Music.UID) {
logD("Opening Artist [uid: $uid]")
_currentArtist.value = requireMusic<Artist>(uid)?.also(::refreshArtistList)
_currentArtist.value =
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
}
/**
* 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.
* Set a new [currentGenre] from it's [Music.UID]. [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) {
if (_currentGenre.value?.uid == uid) {
// Nothing to do.
return
}
fun setGenre(uid: Music.UID) {
logD("Opening Genre [uid: $uid]")
_currentGenre.value = requireMusic<Genre>(uid)?.also(::refreshGenreList)
_currentGenre.value =
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
}
private fun <T : Music> requireMusic(uid: Music.UID) = musicRepository.library?.find<T>(uid)
/**
* Set a new [currentPlaylist] from it's [Music.UID]. If the [Music.UID] differs,
* [currentPlaylist] and [currentPlaylist] will be updated to align with the new album.
*
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
*/
fun setPlaylist(uid: Music.UID) {
logD("Opening Playlist [uid: $uid]")
_currentPlaylist.value =
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
}
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
fun startPlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
logD("Starting playlist edit")
_editedPlaylist.value = playlist.songs
refreshPlaylistList(playlist)
}
/**
* End a playlist editing session and commits it to the database. Does nothing if there was no
* prior editing session.
*/
fun savePlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = _editedPlaylist.value ?: return
viewModelScope.launch {
musicRepository.rewritePlaylist(playlist, editedPlaylist)
// TODO: The user could probably press some kind of button if they were fast enough.
// Think of a better way to handle this state.
_editedPlaylist.value = null
}
}
/**
* End a playlist editing session and keep the prior state. Does nothing if there was no prior
* editing session.
*
* @return true if the session was ended, false otherwise.
*/
fun dropPlaylistEdit(): Boolean {
val playlist = _currentPlaylist.value ?: return false
if (_editedPlaylist.value == null) {
// Nothing to do.
return false
}
_editedPlaylist.value = null
refreshPlaylistList(playlist)
return true
}
/**
* (Visually) move a song in the current playlist. Does nothing if not in an editing session.
*
* @param from The start position, in the list adapter data.
* @param to The destination position, in the list adapter data.
* @return true if the song was moved, false otherwise.
*/
fun movePlaylistSongs(from: Int, to: Int): Boolean {
// TODO: Song re-sorting
val playlist = _currentPlaylist.value ?: return false
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
val realFrom = from - 2
val realTo = to - 2
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
return false
}
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
_editedPlaylist.value = editedPlaylist
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
return true
}
/**
* (Visually) remove a song in the current playlist. Does nothing if not in an editing session.
*
* @param at The position of the item to remove, in the list adapter data.
*/
fun removePlaylistSong(at: Int) {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
val realAt = at - 2
if (realAt !in editedPlaylist.indices) {
return
}
editedPlaylist.removeAt(realAt)
_editedPlaylist.value = editedPlaylist
refreshPlaylistList(
playlist,
if (editedPlaylist.isNotEmpty()) {
UpdateInstructions.Remove(at, 1)
} else {
UpdateInstructions.Remove(at - 2, 3)
})
}
private fun refreshAudioInfo(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
_songAudioInfo.value = null
_songAudioProperties.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
val info = audioInfoProvider.extract(song)
val info = audioPropertiesFactory.extract(song)
yield()
_songAudioInfo.value = info
_songAudioProperties.value = info
}
}
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
logD("Refreshing album data")
logD("Refreshing album list")
val list = mutableListOf<Item>()
list.add(SortHeader(R.string.lbl_songs))
val header = SortHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
val instructions =
if (replace) {
// Intentional so that the header item isn't replaced with the songs
@ -301,7 +417,7 @@ constructor(
}
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
logD("Refreshing artist data")
logD("Refreshing artist list")
val list = mutableListOf<Item>()
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
@ -329,7 +445,9 @@ constructor(
logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
list.add(BasicHeader(entry.key.headerTitleRes))
val header = BasicHeader(entry.key.headerTitleRes)
list.add(Divider(header))
list.add(header)
list.addAll(entry.value)
}
@ -337,7 +455,9 @@ constructor(
var instructions: UpdateInstructions = UpdateInstructions.Diff
if (artist.songs.isNotEmpty()) {
logD("Songs present in this artist, adding header")
list.add(SortHeader(R.string.lbl_songs))
val header = SortHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
if (replace) {
// Intentional so that the header item isn't replaced with the songs
instructions = UpdateInstructions.Replace(list.size)
@ -350,12 +470,17 @@ constructor(
}
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
logD("Refreshing genre data")
logD("Refreshing genre list")
val list = mutableListOf<Item>()
// Genre is guaranteed to always have artists and songs.
list.add(BasicHeader(R.string.lbl_artists))
val artistHeader = BasicHeader(R.string.lbl_artists)
list.add(Divider(artistHeader))
list.add(artistHeader)
list.addAll(genre.artists)
list.add(SortHeader(R.string.lbl_songs))
val songHeader = SortHeader(R.string.lbl_songs)
list.add(Divider(songHeader))
list.add(songHeader)
val instructions =
if (replace) {
// Intentional so that the header item isn't replaced with the songs
@ -368,6 +493,25 @@ constructor(
_genreList.value = list
}
private fun refreshPlaylistList(
playlist: Playlist,
instructions: UpdateInstructions = UpdateInstructions.Diff
) {
logD("Refreshing playlist list")
val list = mutableListOf<Item>()
val songs = editedPlaylist.value ?: playlist.songs
if (songs.isNotEmpty()) {
val header = EditHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
list.addAll(songs)
}
_playlistInstructions.put(instructions)
_playlistList.value = list
}
/**
* A simpler mapping of [ReleaseType] used for grouping and sorting songs.
*

View file

@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
@ -34,18 +35,15 @@ 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.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
/**
@ -61,6 +59,7 @@ class GenreDetailFragment :
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
// 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.
@ -85,18 +84,31 @@ class GenreDetailFragment :
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply {
inflateMenu(R.menu.menu_genre_artist_detail)
binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@GenreDetailFragment)
}
binding.detailRecycler.adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
binding.detailRecycler.apply {
adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.genreList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setGenreUid(args.genreUid)
collectImmediately(detailModel.currentGenre, ::updateItem)
detailModel.setGenre(args.genreUid)
collectImmediately(detailModel.currentGenre, ::updatePlaylist)
collectImmediately(detailModel.genreList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -106,7 +118,7 @@ class GenreDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailToolbar.setOnMenuItemClickListener(null)
binding.detailNormalToolbar.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.
@ -130,6 +142,10 @@ class GenreDetailFragment :
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_playlist_add -> {
musicModel.addToPlaylist(currentGenre)
true
}
else -> false
}
}
@ -154,7 +170,7 @@ class GenreDetailFragment :
override fun onOpenMenu(item: Music, anchor: View) {
when (item) {
is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item)
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
else -> error("Unexpected datatype: ${item::class.simpleName}")
}
@ -170,8 +186,10 @@ class GenreDetailFragment :
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_genre_sort) {
// Select the corresponding sort mode option
val sort = detailModel.genreSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
// Select the corresponding sort direction option
val directionItemId =
when (sort.direction) {
Sort.Direction.ASCENDING -> R.id.option_sort_asc
@ -182,8 +200,10 @@ class GenreDetailFragment :
item.isChecked = !item.isChecked
detailModel.genreSongSort =
when (item.itemId) {
// Sort direction options
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
// Any other option is a sort mode
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
true
@ -191,13 +211,13 @@ class GenreDetailFragment :
}
}
private fun updateItem(genre: Genre?) {
private fun updatePlaylist(genre: Genre?) {
if (genre == null) {
// Genre we were showing no longer exists.
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = genre.resolveName(requireContext())
requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
genreHeaderAdapter.setParent(genre)
}
@ -233,7 +253,7 @@ class GenreDetailFragment :
is Genre -> {
navModel.exploreNavigationItem.consume()
}
null -> {}
else -> {}
}
}
@ -243,6 +263,13 @@ class GenreDetailFragment :
private fun updateSelection(selected: List<Music>) {
genreListAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
} else {
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
}
}

View file

@ -0,0 +1,318 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDetailFragment.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
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
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.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter
import org.oxycblt.auxio.detail.list.PlaylistDragCallback
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.*
/**
* A [ListFragment] that shows information for a particular [Playlist].
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class PlaylistDetailFragment :
ListFragment<Song, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
PlaylistDetailListAdapter.Listener,
NavController.OnDestinationChangedListener {
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what playlist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an playlist.
private val args: PlaylistDetailFragmentArgs by navArgs()
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null
private var initialNavDestinationChange = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_playlist_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
}
binding.detailEditToolbar.apply {
setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
}
binding.detailRecycler.apply {
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
touchHelper =
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
it.attachToRecyclerView(this)
}
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.playlistList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setPlaylist(args.playlistUid)
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
collectImmediately(detailModel.playlistList, ::updateList)
collectImmediately(detailModel.editedPlaylist, ::updateEditedPlaylist)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
}
override fun onStart() {
super.onStart()
// Once we add the destination change callback, we will receive another initialization call,
// so handle that by resetting the flag.
initialNavDestinationChange = false
findNavController().addOnDestinationChangedListener(this)
}
override fun onStop() {
super.onStop()
findNavController().removeOnDestinationChangedListener(this)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
touchHelper = 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.playlistInstructions.consume()
}
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
// Drop the initial call by NavController that simply provides us with the current
// destination. This would cause the selection state to be lost every time the device
// rotates.
if (!initialNavDestinationChange) {
initialNavDestinationChange = true
return
}
// Drop any pending playlist edits when navigating away. This could actually happen
// if the user is quick enough.
detailModel.dropPlaylistEdit()
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) {
return true
}
val currentPlaylist = unlikelyToBeNull(detailModel.currentPlaylist.value)
return when (item.itemId) {
R.id.action_play_next -> {
playbackModel.playNext(currentPlaylist)
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_queue_add -> {
playbackModel.addToQueue(currentPlaylist)
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_rename -> {
musicModel.renamePlaylist(currentPlaylist)
true
}
R.id.action_delete -> {
musicModel.deletePlaylist(currentPlaylist)
true
}
R.id.action_save -> {
detailModel.savePlaylistEdit()
true
}
else -> false
}
}
override fun onRealClick(item: Song) {
playbackModel.playFromPlaylist(item, unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
}
override fun onOpenMenu(item: Song, anchor: View) {
openMusicMenu(anchor, R.menu.menu_playlist_song_actions, item)
}
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onStartEdit() {
detailModel.startPlaylistEdit()
}
override fun onOpenSortMenu(anchor: View) {}
private fun updatePlaylist(playlist: Playlist?) {
if (playlist == null) {
// Playlist we were showing no longer exists.
findNavController().navigateUp()
return
}
val binding = requireBinding()
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext())
binding.detailEditToolbar.title = "Editing ${playlist.name.resolve(requireContext())}"
playlistHeaderAdapter.setParent(playlist)
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
// Prefer songs that might be playing from this playlist.
if (parent is Playlist &&
parent.uid == unlikelyToBeNull(detailModel.currentPlaylist.value).uid) {
playlistListAdapter.setPlaying(song, isPlaying)
} else {
playlistListAdapter.setPlaying(null, isPlaying)
}
}
private fun handleNavigation(item: Music?) {
when (item) {
is Song -> {
logD("Navigating to another song")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.actionShowAlbum(item.album.uid))
}
is Album -> {
logD("Navigating to another album")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.actionShowAlbum(item.uid))
}
is Artist -> {
logD("Navigating to another artist")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.actionShowArtist(item.uid))
}
is Playlist -> {
navModel.exploreNavigationItem.consume()
}
else -> {}
}
}
private fun updateList(list: List<Item>) {
playlistListAdapter.update(list, detailModel.playlistInstructions.consume())
}
private fun updateEditedPlaylist(editedPlaylist: List<Song>?) {
playlistListAdapter.setEditing(editedPlaylist != null)
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
selectionModel.drop()
if (editedPlaylist != null) {
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
}
}
updateMultiToolbar()
}
private fun updateSelection(selected: List<Music>) {
playlistListAdapter.setSelected(selected.toSet())
val binding = requireBinding()
if (selected.isNotEmpty()) {
binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
}
updateMultiToolbar()
}
private fun updateMultiToolbar() {
val id =
when {
detailModel.editedPlaylist.value != null -> R.id.detail_edit_toolbar
selectionModel.selected.value.isNotEmpty() -> R.id.detail_selection_toolbar
else -> R.id.detail_normal_toolbar
}
requireBinding().detailToolbar.setVisible(id)
}
}

View file

@ -34,7 +34,8 @@ 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
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
@ -66,11 +67,11 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
super.onBindingCreated(binding, savedInstanceState)
binding.detailProperties.adapter = detailAdapter
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setSongUid(args.itemUid)
collectImmediately(detailModel.currentSong, detailModel.songAudioInfo, ::updateSong)
detailModel.setSong(args.songUid)
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
}
private fun updateSong(song: Song?, info: AudioInfo?) {
private fun updateSong(song: Song?, info: AudioProperties?) {
if (song == null) {
// Song we were showing no longer exists.
findNavController().navigateUp()
@ -123,12 +124,14 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
}
}
private fun <T : Music> T.zipName(context: Context) =
if (rawSortName != null) {
getString(R.string.fmt_zipped_names, resolveName(context), rawSortName)
private fun <T : Music> T.zipName(context: Context): String {
val name = name
return if (name is Name.Known && name.sort != null) {
getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
} else {
resolveName(context)
name.resolve(context)
}
}
private fun <T : Music> List<T>.zipNames(context: Context) =
concatLocalized(context) { it.zipName(context) }

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Album] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderAdapter(private val listener: Listener) :
@ -76,7 +77,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
// 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)
binding.detailName.text = album.name.resolve(binding.context)
// Artist name maps to the subhead text
binding.detailSubhead.apply {

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Artist] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderAdapter(private val listener: Listener) :
@ -62,7 +63,18 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
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)
binding.detailName.text = artist.name.resolve(binding.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),
if (artist.songs.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
} else {
binding.context.getString(R.string.def_song_count)
})
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text
@ -71,13 +83,6 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
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
@ -87,10 +92,8 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
// 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.isEnabled = false
binding.detailShuffleButton.isEnabled = false
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }

View file

@ -48,10 +48,17 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
*/
fun setParent(parent: T) {
currentParent = parent
rebindParent()
}
/**
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
*/
protected fun rebindParent() {
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
}
/** An extended listener for [DetailHeaderAdapter] implementations. */
/** A listener for [DetailHeaderAdapter] implementations. */
interface Listener {
/**
* Called when the play button in a detail header is pressed, requesting that the current

View file

@ -24,7 +24,6 @@ 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
@ -33,6 +32,7 @@ import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Genre] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailHeaderAdapter(private val listener: Listener) :
@ -57,15 +57,15 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
* Bind new data to this instance.
*
* @param genre The new [Genre] to bind.
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @param listener A [DetailHeaderAdapter.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)
binding.detailName.text = genre.name.resolve(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.
// The song and artist count of the genre maps to the info text.
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,

View file

@ -0,0 +1,126 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDetailHeaderAdapter.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.Playlist
import org.oxycblt.auxio.music.Song
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 [Playlist] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
private var editedPlaylist: List<Song>? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
PlaylistDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) =
holder.bind(parent, editedPlaylist, listener)
/**
* Indicate to this adapter that editing is ongoing with the current state of the editing
* process. This will make the header immediately update to reflect information about the edited
* playlist.
*/
fun setEditedPlaylist(songs: List<Song>?) {
if (editedPlaylist == songs) {
// Nothing to do.
return
}
editedPlaylist = songs
rebindParent()
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Playlist] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param playlist The new [Playlist] to bind.
* @param editedPlaylist The current edited state of the playlist, if it exists.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(
playlist: Playlist,
editedPlaylist: List<Song>?,
listener: DetailHeaderAdapter.Listener
) {
// TODO: Debug perpetually re-binding images
binding.detailCover.bind(playlist, editedPlaylist)
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
val songs = editedPlaylist ?: playlist.songs
val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs
// The song count of the playlist maps to the info text.
binding.detailInfo.text =
if (songs.isNotEmpty()) {
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
durationMs.formatDurationMs(true))
} else {
binding.context.getString(R.string.def_song_count)
}
binding.detailPlayButton.apply {
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
setOnClickListener { listener.onPlay() }
}
binding.detailShuffleButton.apply {
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
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) =
PlaylistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -33,7 +33,7 @@ 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.metadata.Disc
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
@ -69,15 +69,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
}
}
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// The album and disc headers should be full-width in all configurations.
val item = getItem(position)
return item is Album || item is Disc
}
private companion object {
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@ -171,7 +162,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
}
}
binding.songName.text = song.resolveName(binding.context)
binding.songName.text = song.name.resolve(binding.context)
// Use duration instead of album or artist for each song, as this text would
// be homogenous otherwise.
@ -204,7 +195,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
val DIFF_CALLBACK =
object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
oldItem.name == newItem.name && oldItem.durationMs == newItem.durationMs
}
}
}

View file

@ -65,14 +65,6 @@ class ArtistDetailListAdapter(private val listener: Listener<Music>) :
}
}
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// Artist headers should be full-width in all configurations.
return getItem(position) is Artist
}
private companion object {
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@ -106,7 +98,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
fun bind(album: Album, listener: SelectableListListener<Album>) {
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information
album.dates?.resolveDate(binding.context)
@ -139,7 +131,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
val DIFF_CALLBACK =
object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName && oldItem.dates == newItem.dates
oldItem.name == newItem.name && oldItem.dates == newItem.dates
}
}
}
@ -161,8 +153,8 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
fun bind(song: Song, listener: SelectableListListener<Song>) {
listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songInfo.text = song.album.resolveName(binding.context)
binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.album.name.resolve(binding.context)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@ -191,8 +183,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
val DIFF_CALLBACK =
object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName &&
oldItem.album.rawName == newItem.album.rawName
oldItem.name == newItem.name && oldItem.album.name == newItem.album.name
}
}
}

View file

@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
@ -47,13 +48,12 @@ import org.oxycblt.auxio.util.inflater
abstract class DetailListAdapter(
private val listener: Listener<*>,
private val diffCallback: DiffUtil.ItemCallback<Item>
) :
SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback),
AuxioRecyclerView.SpanSizeLookup {
) : SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Implement support for headers and sort headers
is Divider -> DividerViewHolder.VIEW_TYPE
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
@ -61,6 +61,7 @@ abstract class DetailListAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent)
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType")
@ -73,12 +74,6 @@ abstract class DetailListAdapter(
}
}
override fun isItemFullWidth(position: Int): Boolean {
// Headers should be full-width in all configurations.
val item = getItem(position)
return item is BasicHeader || item is SortHeader
}
/** An extended [SelectableListListener] for [DetailListAdapter] implementations. */
interface Listener<in T : Music> : SelectableListListener<T> {
/**
@ -94,6 +89,8 @@ abstract class DetailListAdapter(
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Divider && newItem is Divider ->
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is BasicHeader && newItem is BasicHeader ->
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader ->
@ -114,8 +111,8 @@ abstract class DetailListAdapter(
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.
* A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create
* an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@ -129,7 +126,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
*/
fun bind(sortHeader: SortHeader, listener: DetailListAdapter.Listener<*>) {
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
binding.headerButton.apply {
binding.headerSort.apply {
// Add a Tooltip based on the content description so that the purpose of this
// button can be clear.
TooltipCompat.setTooltipText(this, contentDescription)

View file

@ -30,7 +30,7 @@ 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.
* A [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)
@ -60,14 +60,6 @@ class GenreDetailListAdapter(private val listener: Listener<Music>) :
}
}
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>() {

View file

@ -0,0 +1,281 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDetailListAdapter.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.annotation.SuppressLint
import android.graphics.drawable.LayerDrawable
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemEditHeaderBinding
import org.oxycblt.auxio.databinding.ItemEditableSongBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.inflater
/**
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
* detail view.
*
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailListAdapter(private val listener: Listener) :
DetailListAdapter(listener, DIFF_CALLBACK) {
private var isEditing = false
override fun getItemViewType(position: Int) =
when (getItem(position)) {
is EditHeader -> EditHeaderViewHolder.VIEW_TYPE
is Song -> PlaylistSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
EditHeaderViewHolder.VIEW_TYPE -> EditHeaderViewHolder.from(parent)
PlaylistSongViewHolder.VIEW_TYPE -> PlaylistSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: List<Any>
) {
super.onBindViewHolder(holder, position, payloads)
if (payloads.isEmpty()) {
when (val item = getItem(position)) {
is EditHeader -> (holder as EditHeaderViewHolder).bind(item, listener)
is Song -> (holder as PlaylistSongViewHolder).bind(item, listener)
}
}
if (holder is ViewHolder) {
holder.updateEditing(isEditing)
}
}
fun setEditing(editing: Boolean) {
if (editing == isEditing) {
// Nothing to do.
return
}
this.isEditing = editing
notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED)
}
/** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */
interface Listener : DetailListAdapter.Listener<Song>, EditableListListener {
/** Called when the "edit" option is selected in the edit header. */
fun onStartEdit()
}
/**
* A [RecyclerView.ViewHolder] extension required to respond to changes in the editing state.
*/
interface ViewHolder {
/**
* Called when the editing state changes. Implementations should update UI options as needed
* to reflect the new state.
*
* @param editing Whether the data is currently being edited or not.
*/
fun updateEditing(editing: Boolean)
}
private companion object {
val PAYLOAD_EDITING_CHANGED = Any()
val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Song && newItem is Song ->
PlaylistSongViewHolder.DIFF_CALLBACK.areContentsTheSame(
oldItem, newItem)
oldItem is EditHeader && newItem is EditHeader ->
EditHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
}
}
}
}
/**
* A [Header] variant that displays an edit button.
*
* @param titleRes The string resource to use as the header title
* @author Alexander Capehart (OxygenCobalt)
*/
data class EditHeader(@StringRes override val titleRes: Int) : Header
/**
* Displays an [EditHeader] and it's actions. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class EditHeaderViewHolder private constructor(private val binding: ItemEditHeaderBinding) :
RecyclerView.ViewHolder(binding.root), PlaylistDetailListAdapter.ViewHolder {
/**
* Bind new data to this instance.
*
* @param editHeader The new [EditHeader] to bind.
* @param listener An [PlaylistDetailListAdapter.Listener] to bind interactions to.
*/
fun bind(editHeader: EditHeader, listener: PlaylistDetailListAdapter.Listener) {
binding.headerTitle.text = binding.context.getString(editHeader.titleRes)
// Add a Tooltip based on the content description so that the purpose of this
// button can be clear.
binding.headerEdit.apply {
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener { listener.onStartEdit() }
}
}
override fun updateEditing(editing: Boolean) {
binding.headerEdit.isEnabled = !editing
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_EDIT_HEADER
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
EditHeaderViewHolder(ItemEditHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<EditHeader>() {
override fun areContentsTheSame(oldItem: EditHeader, newItem: EditHeader) =
oldItem.titleRes == newItem.titleRes
}
}
}
/**
* A [PlayingIndicatorAdapter.ViewHolder] that displays a queue [Song] which can be re-ordered and
* removed. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private class PlaylistSongViewHolder
private constructor(private val binding: ItemEditableSongBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root),
MaterialDragCallback.ViewHolder,
PlaylistDetailListAdapter.ViewHolder {
override val enabled: Boolean
get() = binding.songDragHandle.isVisible
override val root = binding.root
override val body = binding.body
override val delete = binding.background
override val background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
elevation = binding.context.getDimen(R.dimen.elevation_normal)
alpha = 0
}
init {
binding.body.background =
LayerDrawable(
arrayOf(
MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply {
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
},
background))
}
/**
* Bind new data to this instance.
*
* @param song The new [Song] to bind.
* @param listener A [PlaylistDetailListAdapter.Listener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(song: Song, listener: PlaylistDetailListAdapter.Listener) {
listener.bind(song, this, binding.interactBody, menuButton = binding.songMenu)
listener.bind(this, binding.songDragHandle)
binding.songAlbumCover.bind(song)
binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.artists.resolveNames(binding.context)
// Not swiping this ViewHolder if it's being re-bound, ensure that the background is
// not visible. See MaterialDragCallback for why this is done.
binding.background.isInvisible = true
}
override fun updateSelectionIndicator(isSelected: Boolean) {
binding.interactBody.isActivated = isSelected
binding.songAlbumCover.isActivated = isSelected
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.interactBody.isSelected = isActive
binding.songAlbumCover.isPlaying = isPlaying
}
override fun updateEditing(editing: Boolean) {
binding.songDragHandle.isInvisible = !editing
binding.songMenu.isInvisible = editing
binding.interactBody.isEnabled = !editing
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_PLAYLIST_SONG
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
PlaylistSongViewHolder(ItemEditableSongBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDragCallback.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 androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
/**
* A [MaterialDragCallback] extension for playlist-specific item editing.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDragCallback(private val detailModel: DetailViewModel) : MaterialDragCallback() {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
) =
detailModel.movePlaylistSongs(
viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
detailModel.removePlaylistSong(viewHolder.bindingAdapterPosition)
}
}

View file

@ -0,0 +1,105 @@
/*
* Copyright (c) 2023 Auxio Project
* FlipFloatingActionButton.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.google.android.material.floatingactionbutton.FloatingActionButton
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.logD
/**
* An extension of [FloatingActionButton] that enables the ability to fade in and out between
* several states, as in the Material Design 3 specification.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class FlipFloatingActionButton
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.floatingActionButtonStyle
) : FloatingActionButton(context, attrs, defStyleAttr) {
private var pendingConfig: PendingConfig? = null
private var flipping = false
override fun show() {
// Will already show eventually, need to do nothing.
if (flipping) return
// Apply the new configuration possibly set in flipTo. This should occur even if
// a flip was canceled by a hide.
pendingConfig?.run {
setImageResource(iconRes)
contentDescription = context.getString(contentDescriptionRes)
setOnClickListener(clickListener)
}
pendingConfig = null
super.show()
}
override fun hide() {
// Not flipping anymore, disable the flag so that the FAB is not re-shown.
flipping = false
// Don't pass any kind of listener so that future flip operations will not be able
// to show the FAB again.
super.hide()
}
/**
* Flip to a new FAB state.
*
* @param iconRes The resource of the new FAB icon.
* @param contentDescriptionRes The resource of the new FAB content description.
*/
fun flipTo(
@DrawableRes iconRes: Int,
@StringRes contentDescriptionRes: Int,
clickListener: OnClickListener
) {
// Avoid doing a flip if the given config is already being applied.
if (tag == iconRes) return
tag = iconRes
pendingConfig = PendingConfig(iconRes, contentDescriptionRes, clickListener)
// Already hiding for whatever reason, apply the configuration when the FAB is shown again.
if (!isOrWillBeHidden) {
flipping = true
// We will re-show the FAB later, assuming that there was not a prior flip operation.
super.hide(FlipVisibilityListener())
}
}
private data class PendingConfig(
@DrawableRes val iconRes: Int,
@StringRes val contentDescriptionRes: Int,
val clickListener: OnClickListener
)
private inner class FlipVisibilityListener : OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
if (!flipping) return
logD("Showing for a flip operation")
flipping = false
show()
}
}
}

View file

@ -46,20 +46,15 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding
import org.oxycblt.auxio.home.list.AlbumListFragment
import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.list.*
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionFragment
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.music.system.Indexer
import org.oxycblt.auxio.navigation.MainNavigationAction
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
/**
@ -73,8 +68,8 @@ class HomeFragment :
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
override val playbackModel: PlaybackViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
@ -107,7 +102,7 @@ class HomeFragment :
// --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeToolbar.apply {
binding.homeNormalToolbar.apply {
setOnMenuItemClickListener(this@HomeFragment)
MenuCompat.setGroupDividerEnabled(menu, true)
}
@ -152,13 +147,11 @@ class HomeFragment :
// re-creating the ViewPager.
setupPager(binding)
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
// --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate)
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.indexerState, ::updateIndexerState)
collectImmediately(musicModel.indexingState, ::updateIndexerState)
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
}
@ -176,7 +169,7 @@ class HomeFragment :
super.onDestroyBinding(binding)
storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(null)
binding.homeNormalToolbar.setOnMenuItemClickListener(null)
}
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
@ -185,8 +178,7 @@ class HomeFragment :
// Fade out the toolbar as the AppBarLayout collapses. To prevent status bar overlap,
// the alpha transition is shifted such that the Toolbar becomes fully transparent
// when the AppBarLayout is only at half-collapsed.
binding.homeSelectionToolbar.alpha =
1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
binding.homeToolbar.alpha = 1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
binding.homeContent.updatePadding(
bottom = binding.homeAppbar.totalScrollRange + verticalOffset)
}
@ -250,7 +242,7 @@ class HomeFragment :
binding.homePager.adapter =
HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner)
val toolbarParams = binding.homeSelectionToolbar.layoutParams as AppBarLayout.LayoutParams
val toolbarParams = binding.homeToolbar.layoutParams as AppBarLayout.LayoutParams
if (homeModel.currentTabModes.size == 1) {
// A single tab makes the tab layout redundant, hide it and disable the collapsing
// behavior.
@ -273,6 +265,7 @@ class HomeFragment :
}
private fun updateCurrentTab(tabMode: MusicMode) {
val binding = requireBinding()
// Update the sort options to align with those allowed by the tab
val isVisible: (Int) -> Boolean =
when (tabMode) {
@ -280,16 +273,8 @@ class HomeFragment :
MusicMode.SONGS -> { id -> id != R.id.option_sort_count }
// Disallow sorting by album for albums
MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album }
// Only allow sorting by name, count, and duration for artists
MusicMode.ARTISTS -> { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_dec ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
id == R.id.option_sort_duration
}
// Only allow sorting by name, count, and duration for genres
MusicMode.GENRES -> { id ->
// Only allow sorting by name, count, and duration for parents
else -> { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_dec ||
id == R.id.option_sort_name ||
@ -299,8 +284,7 @@ class HomeFragment :
}
val sortMenu =
unlikelyToBeNull(
requireBinding().homeToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
unlikelyToBeNull(binding.homeNormalToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
val toHighlight = homeModel.getSortForTab(tabMode)
for (option in sortMenu) {
@ -321,13 +305,24 @@ class HomeFragment :
// Update the scrolling view in AppBarLayout to align with the current tab's
// scrolling state. This prevents the lift state from being confused as one
// goes between different tabs.
requireBinding().homeAppbar.liftOnScrollTargetViewId =
binding.homeAppbar.liftOnScrollTargetViewId =
when (tabMode) {
MusicMode.SONGS -> R.id.home_song_recycler
MusicMode.ALBUMS -> R.id.home_album_recycler
MusicMode.ARTISTS -> R.id.home_artist_recycler
MusicMode.GENRES -> R.id.home_genre_recycler
MusicMode.PLAYLISTS -> R.id.home_playlist_recycler
}
if (tabMode != MusicMode.PLAYLISTS) {
binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
playbackModel.shuffleAll()
}
} else {
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
musicModel.createPlaylist()
}
}
}
private fun handleRecreate(recreate: Unit?) {
@ -340,14 +335,14 @@ class HomeFragment :
homeModel.recreateTabs.consume()
}
private fun updateIndexerState(state: Indexer.State?) {
private fun updateIndexerState(state: IndexingState?) {
// TODO: Make music loading experience a bit more pleasant
// 1. Loading placeholder for item lists
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
val binding = requireBinding()
when (state) {
is Indexer.State.Complete -> setupCompleteState(binding, state.result)
is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
is IndexingState.Completed -> setupCompleteState(binding, state.error)
is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
null -> {
logD("Indexer is in indeterminate state")
binding.homeIndexingContainer.visibility = View.INVISIBLE
@ -355,77 +350,77 @@ class HomeFragment :
}
}
private fun setupCompleteState(binding: FragmentHomeBinding, result: Result<Library>) {
if (result.isSuccess) {
private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) {
if (error == null) {
logD("Received ok response")
binding.homeFab.show()
binding.homeIndexingContainer.visibility = View.INVISIBLE
} else {
logD("Received non-ok response")
val context = requireContext()
val throwable = unlikelyToBeNull(result.exceptionOrNull())
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
when (throwable) {
is Indexer.NoPermissionException -> {
logD("Updating UI to permission request state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_grant)
setOnClickListener {
requireNotNull(storagePermissionLauncher) {
"Permission launcher was not available"
}
.launch(Indexer.PERMISSION_READ_AUDIO)
}
return
}
logD("Received non-ok response")
val context = requireContext()
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
when (error) {
is NoAudioPermissionException -> {
logD("Updating UI to permission request state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_grant)
setOnClickListener {
requireNotNull(storagePermissionLauncher) {
"Permission launcher was not available"
}
.launch(PERMISSION_READ_AUDIO)
}
}
is Indexer.NoMusicException -> {
logD("Updating UI to no music state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
}
is NoMusicException -> {
logD("Updating UI to no music state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
else -> {
logD("Updating UI to error state")
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.rescan() }
}
}
else -> {
logD("Updating UI to error state")
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.rescan() }
}
}
}
}
private fun setupIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) {
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
// Remove all content except for the progress indicator.
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.VISIBLE
binding.homeIndexingAction.visibility = View.INVISIBLE
when (indexing) {
is Indexer.Indexing.Indeterminate -> {
when (progress) {
is IndexingProgress.Indeterminate -> {
// In a query/initialization state, show a generic loading status.
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
binding.homeIndexingProgress.isIndeterminate = true
}
is Indexer.Indexing.Songs -> {
is IndexingProgress.Songs -> {
// Actively loading songs, show the current progress.
binding.homeIndexingStatus.text =
getString(R.string.fmt_indexing, indexing.current, indexing.total)
getString(R.string.fmt_indexing, progress.current, progress.total)
binding.homeIndexingProgress.apply {
isIndeterminate = false
max = indexing.total
progress = indexing.current
max = progress.total
this.progress = progress.current
}
}
}
@ -450,7 +445,8 @@ class HomeFragment :
is Album -> HomeFragmentDirections.actionShowAlbum(item.uid)
is Artist -> HomeFragmentDirections.actionShowArtist(item.uid)
is Genre -> HomeFragmentDirections.actionShowGenre(item.uid)
else -> return
is Playlist -> HomeFragmentDirections.actionShowPlaylist(item.uid)
null -> return
}
setupAxisTransitions(MaterialSharedAxis.X)
@ -459,11 +455,15 @@ class HomeFragment :
private fun updateSelection(selected: List<Music>) {
val binding = requireBinding()
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) {
// New selection started, show the AppBarLayout to indicate the new state.
logD("Significant selection occurred, expanding AppBar")
binding.homeAppbar.expandWithScrollingRecycler()
if (selected.isNotEmpty()) {
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
// New selection started, show the AppBarLayout to indicate the new state.
logD("Significant selection occurred, expanding AppBar")
binding.homeAppbar.expandWithScrollingRecycler()
}
} else {
binding.homeToolbar.setVisible(R.id.home_normal_toolbar)
}
}
@ -499,6 +499,7 @@ class HomeFragment :
MusicMode.ALBUMS -> AlbumListFragment()
MusicMode.ARTISTS -> ArtistListFragment()
MusicMode.GENRES -> GenreListFragment()
MusicMode.PLAYLISTS -> PlaylistListFragment()
}
}

View file

@ -24,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -64,10 +65,32 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
override val shouldHideCollaborators: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false)
override fun migrate() {
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
val oldTabs =
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
// The playlist tab is now parsed, but it needs to be made visible.
val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS }
if (playlistIndex > -1) { // Sanity check
oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS)
}
sharedPreferences.edit {
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
remove(OLD_KEY_LIB_TABS)
}
}
}
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
when (key) {
getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged()
}
}
companion object {
const val OLD_KEY_LIB_TABS = "auxio_lib_tabs"
}
}

View file

@ -27,7 +27,6 @@ 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
@ -46,7 +45,7 @@ constructor(
private val playbackSettings: PlaybackSettings,
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings
) : ViewModel(), MusicRepository.Listener, HomeSettings.Listener {
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
private val _songsList = MutableStateFlow(listOf<Song>())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
@ -88,6 +87,15 @@ constructor(
val genresInstructions: Event<UpdateInstructions>
get() = _genresInstructions
private val _playlistsList = MutableStateFlow(listOf<Playlist>())
/** A list of [Playlist]s, sorted by the preferred [Sort], to be shown in the home view. */
val playlistsList: StateFlow<List<Playlist>>
get() = _playlistsList
private val _playlistsInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [genresList] in the UI. */
val playlistsInstructions: Event<UpdateInstructions>
get() = _playlistsInstructions
/** The [MusicMode] to use when playing a [Song] from the UI. */
val playbackMode: MusicMode
get() = playbackSettings.inListPlaybackMode
@ -117,37 +125,45 @@ constructor(
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
init {
musicRepository.addListener(this)
musicRepository.addUpdateListener(this)
homeSettings.registerListener(this)
}
override fun onCleared() {
super.onCleared()
musicRepository.removeListener(this)
musicRepository.removeUpdateListener(this)
homeSettings.unregisterListener(this)
}
override fun onLibraryChanged(library: Library?) {
if (library != null) {
logD("Library changed, refreshing library")
// FIXME: Sort name setting changes result in incorrect list updates
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary
logD(changes.deviceLibrary)
if (changes.deviceLibrary && deviceLibrary != null) {
logD("Refreshing library")
// 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)
_songsList.value = musicSettings.songSort.songs(deviceLibrary.songs)
_albumsInstructions.put(UpdateInstructions.Diff)
_albumsLists.value = musicSettings.albumSort.albums(library.albums)
_albumsLists.value = musicSettings.albumSort.albums(deviceLibrary.albums)
_artistsInstructions.put(UpdateInstructions.Diff)
_artistsList.value =
musicSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) {
// Hide Collaborators is enabled, filter out collaborators.
library.artists.filter { !it.isCollaborator }
deviceLibrary.artists.filter { !it.isCollaborator }
} else {
library.artists
deviceLibrary.artists
})
_genresInstructions.put(UpdateInstructions.Diff)
_genresList.value = musicSettings.genreSort.genres(library.genres)
_genresList.value = musicSettings.genreSort.genres(deviceLibrary.genres)
}
val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) {
logD("Refreshing playlists")
_playlistsInstructions.put(UpdateInstructions.Diff)
_playlistsList.value = musicSettings.playlistSort.playlists(userLibrary.playlists)
}
}
@ -160,7 +176,7 @@ constructor(
override fun onHideCollaboratorsChanged() {
// Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update.
onLibraryChanged(musicRepository.library)
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
}
/**
@ -175,6 +191,7 @@ constructor(
MusicMode.ALBUMS -> musicSettings.albumSort
MusicMode.ARTISTS -> musicSettings.artistSort
MusicMode.GENRES -> musicSettings.genreSort
MusicMode.PLAYLISTS -> musicSettings.playlistSort
}
/**
@ -206,6 +223,11 @@ constructor(
_genresInstructions.put(UpdateInstructions.Replace(0))
_genresList.value = sort.genres(_genresList.value)
}
MusicMode.PLAYLISTS -> {
musicSettings.playlistSort = sort
_playlistsInstructions.put(UpdateInstructions.Replace(0))
_playlistsList.value = sort.playlists(_playlistsList.value)
}
}
}

View file

@ -37,10 +37,10 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
/**
@ -56,6 +56,7 @@ class AlbumListFragment :
private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
private val albumAdapter = AlbumAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text
@ -94,10 +95,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.sortName?.thumbString
is Sort.Mode.ByName -> album.name.thumb
// By Artist -> Use name of first artist
is Sort.Mode.ByArtist -> album.artists[0].sortName?.thumbString
is Sort.Mode.ByArtist -> album.artists[0].name.thumb
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }

View file

@ -38,9 +38,10 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.navigation.NavigationViewModel
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
@ -58,6 +59,7 @@ class ArtistListFragment :
private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
private val artistAdapter = ArtistAdapter(this)
@ -93,7 +95,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.sortName?.thumbString
is Sort.Mode.ByName -> artist.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
@ -115,7 +117,7 @@ class ArtistListFragment :
}
override fun onOpenMenu(item: Artist, anchor: View) {
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
openMusicMenu(anchor, R.menu.menu_parent_actions, item)
}
private fun updateArtists(artists: List<Artist>) {

View file

@ -38,9 +38,10 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.navigation.NavigationViewModel
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
@ -57,6 +58,7 @@ class GenreListFragment :
private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
private val genreAdapter = GenreAdapter(this)
@ -92,7 +94,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.sortName?.thumbString
is Sort.Mode.ByName -> genre.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@ -114,7 +116,7 @@ class GenreListFragment :
}
override fun onOpenMenu(item: Genre, anchor: View) {
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
openMusicMenu(anchor, R.menu.menu_parent_actions, item)
}
private fun updateGenres(genres: List<Genre>) {

View file

@ -0,0 +1,150 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistListFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
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.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
/**
* A [ListFragment] that shows a list of [Playlist]s.
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Show a placeholder when there are no playlists.
*/
class PlaylistListFragment :
ListFragment<Playlist, FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
private val playlistAdapter = PlaylistAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply {
id = R.id.home_playlist_recycler
adapter = playlistAdapter
popupProvider = this@PlaylistListFragment
listener = this@PlaylistListFragment
}
collectImmediately(homeModel.playlistsList, ::updatePlaylists)
collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
}
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
super.onDestroyBinding(binding)
binding.homeRecycler.apply {
adapter = null
popupProvider = null
listener = null
}
}
override fun getPopup(pos: Int): String? {
val playlist = homeModel.playlistsList.value[pos]
// 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 -> playlist.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
// Count -> Use song count
is Sort.Mode.ByCount -> playlist.songs.size.toString()
// Unsupported sort, error gracefully
else -> null
}
}
override fun onFastScrollingChanged(isFastScrolling: Boolean) {
homeModel.setFastScrolling(isFastScrolling)
}
override fun onRealClick(item: Playlist) {
navModel.exploreNavigateTo(item)
}
override fun onOpenMenu(item: Playlist, anchor: View) {
openMusicMenu(anchor, R.menu.menu_playlist_actions, item)
}
private fun updatePlaylists(playlists: List<Playlist>) {
playlistAdapter.update(
playlists, homeModel.playlistsInstructions.consume().also { logD(it) })
}
private fun updateSelection(selection: List<Music>) {
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
// If a playlist is playing, highlight it within this adapter.
playlistAdapter.setPlaying(parent as? Playlist, isPlaying)
}
/**
* A [SelectionIndicatorAdapter] that shows a list of [Playlist]s using [PlaylistViewHolder].
*
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class PlaylistAdapter(private val listener: SelectableListListener<Playlist>) :
SelectionIndicatorAdapter<Playlist, PlaylistViewHolder>(PlaylistViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
PlaylistViewHolder.from(parent)
override fun onBindViewHolder(holder: PlaylistViewHolder, position: Int) {
holder.bind(getItem(position), listener)
}
}
}

View file

@ -39,11 +39,12 @@ import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
/**
@ -59,6 +60,7 @@ class SongListFragment :
private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text
@ -100,13 +102,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.sortName?.thumbString
is Sort.Mode.ByName -> song.name.thumb
// Artist -> Use name of first artist
is Sort.Mode.ByArtist -> song.album.artists[0].sortName?.thumbString
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb
// Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.sortName?.thumbString
is Sort.Mode.ByAlbum -> song.album.name.thumb
// Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())

View file

@ -58,6 +58,10 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicMode>) :
icon = R.drawable.ic_genre_24
string = R.string.lbl_genres
}
MusicMode.PLAYLISTS -> {
icon = R.drawable.ic_playlist_24
string = R.string.lbl_playlists
}
}
// Use expected sw* size thresholds when choosing a configuration.

View file

@ -49,7 +49,7 @@ sealed class Tab(open val mode: MusicMode) {
//
// 0bTAB1_TAB2_TAB3_TAB4_TAB5
//
// Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists.
// Where TABN is a chunk representing a tab at position N.
// Each chunk in a sequence is represented as:
//
// VTTT
@ -57,18 +57,23 @@ sealed class Tab(open val mode: MusicMode) {
// Where V is a bit representing the visibility and T is a 3-bit integer representing the
// MusicMode for this tab.
/** The length a well-formed tab sequence should be. */
private const val SEQUENCE_LEN = 4
/** The maximum index that a well-formed tab sequence should be. */
private const val MAX_SEQUENCE_IDX = 4
/**
* The default tab sequence, in integer form. This represents a set of four visible tabs
* ordered as "Song", "Album", "Artist", and "Genre".
* ordered as "Song", "Album", "Artist", "Genre", and "Playlists
*/
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_1100
/** Maps between the integer code in the tab sequence and it's [MusicMode]. */
private val MODE_TABLE =
arrayOf(MusicMode.SONGS, MusicMode.ALBUMS, MusicMode.ARTISTS, MusicMode.GENRES)
arrayOf(
MusicMode.SONGS,
MusicMode.ALBUMS,
MusicMode.ARTISTS,
MusicMode.GENRES,
MusicMode.PLAYLISTS)
/**
* Convert an array of [Tab]s into it's integer representation.
@ -80,8 +85,8 @@ sealed class Tab(open val mode: MusicMode) {
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
val distinct = tabs.distinctBy { it.mode }
var sequence = 0b0100
var shift = SEQUENCE_LEN * 4
var sequence = 0
var shift = MAX_SEQUENCE_IDX * 4
for (tab in distinct) {
val bin =
when (tab) {
@ -107,9 +112,8 @@ sealed class Tab(open val mode: MusicMode) {
// Try to parse a mode for each chunk in the sequence.
// If we can't parse one, just skip it.
for (shift in (0..4 * SEQUENCE_LEN).reversed() step 4) {
for (shift in (0..MAX_SEQUENCE_IDX * 4).reversed() step 4) {
val chunk = intCode.shr(shift) and 0b1111
val mode = MODE_TABLE.getOrNull(chunk and 7) ?: continue
// Figure out the visibility
@ -125,7 +129,7 @@ sealed class Tab(open val mode: MusicMode) {
val distinct = tabs.distinctBy { it.mode }
// For safety, return null if we have an empty or larger-than-expected tab array.
if (distinct.isEmpty() || distinct.size < SEQUENCE_LEN) {
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
logE("Sequence size was ${distinct.size}, which is invalid")
return null
}

View file

@ -24,7 +24,7 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemTabBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.inflater
@ -32,9 +32,9 @@ 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.
* @param listener A [EditClickListListener] for tab interactions.
*/
class TabAdapter(private val listener: EditableListListener<Tab>) :
class TabAdapter(private val listener: EditClickListListener<Tab>) :
RecyclerView.Adapter<TabViewHolder>() {
/** The current array of [Tab]s. */
var tabs = arrayOf<Tab>()
@ -97,10 +97,10 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
* Bind new data to this instance.
*
* @param tab The new [Tab] to bind.
* @param listener A [EditableListListener] to bind interactions to.
* @param listener A [EditClickListListener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(tab: Tab, listener: EditableListListener<Tab>) {
fun bind(tab: Tab, listener: EditClickListListener<Tab>) {
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
binding.tabCheckBox.apply {
// Update the CheckBox name to align with the mode
@ -110,6 +110,7 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
MusicMode.ALBUMS -> R.string.lbl_albums
MusicMode.ARTISTS -> R.string.lbl_artists
MusicMode.GENRES -> R.string.lbl_genres
MusicMode.PLAYLISTS -> R.string.lbl_playlists
})
// Unlike in other adapters, we update the checked state alongside

View file

@ -29,7 +29,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.logD
@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.logD
*/
@AndroidEntryPoint
class TabCustomizeDialog :
ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener<Tab> {
ViewBindingDialogFragment<DialogTabsBinding>(), EditClickListListener<Tab> {
private val tabAdapter = TabAdapter(this)
private var touchHelper: ItemTouchHelper? = null
@Inject lateinit var homeSettings: HomeSettings

View file

@ -95,7 +95,7 @@ constructor(
target
.onConfigRequest(
ImageRequest.Builder(context)
.data(song)
.data(listOf(song))
// Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL)
.transformations(SquareFrameTransform.INSTANCE))

View file

@ -30,10 +30,7 @@ import androidx.annotation.AttrRes
import androidx.core.view.updateMarginsRelative
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimenPixels
@ -52,6 +49,9 @@ import org.oxycblt.auxio.util.getInteger
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Rework content descriptions here
* TODO: Attempt unification with StyledImageView with some kind of dynamic configuration to avoid
* superfluous elements
* TODO: Handle non-square covers by gracefully placing them in the layout
*/
class ImageGroup
@JvmOverloads
@ -177,6 +177,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(genre: Genre) = innerImageView.bind(genre)
/**
* Bind a [Playlist]'s image to the internal [StyledImageView].
*
* @param playlist the [Playlist] to bind.
* @see StyledImageView.bind
*/
fun bind(playlist: Playlist) = innerImageView.bind(playlist)
/**
* Whether this view should be indicated to have ongoing playback or not. See
* PlaybackIndicatorView for more information on what occurs here. Note: It's expected for this

View file

@ -18,16 +18,10 @@
package org.oxycblt.auxio.image
import android.content.Context
import coil.ImageLoader
import coil.request.CachePolicy
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.oxycblt.auxio.image.extractor.*
@Module
@ -35,31 +29,3 @@ import org.oxycblt.auxio.image.extractor.*
interface ImageModule {
@Binds fun settings(imageSettings: ImageSettingsImpl): ImageSettings
}
@Module
@InstallIn(SingletonComponent::class)
class CoilModule {
@Singleton
@Provides
fun imageLoader(
@ApplicationContext context: Context,
songFactory: AlbumCoverFetcher.SongFactory,
albumFactory: AlbumCoverFetcher.AlbumFactory,
artistFactory: ArtistImageFetcher.Factory,
genreFactory: GenreImageFetcher.Factory
) =
ImageLoader.Builder(context)
.components {
// Add fetchers for Music components to make them usable with ImageRequest
add(MusicKeyer())
add(songFactory)
add(albumFactory)
add(artistFactory)
add(genreFactory)
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())
// Not downloading anything, so no disk-caching
.diskCachePolicy(CachePolicy.DISABLED)
.build()
}

View file

@ -38,11 +38,7 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Album
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.music.*
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
@ -100,41 +96,54 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*
* @param song The [Song] to bind.
*/
fun bind(song: Song) = bindImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover)
fun bind(song: Song) = bind(song.album)
/**
* Bind an [Album]'s cover to this view, also updating the content description.
*
* @param album the [Album] to bind.
*/
fun bind(album: Album) = bindImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover)
fun bind(album: Album) = bind(album, R.drawable.ic_album_24, R.string.desc_album_cover)
/**
* Bind an [Artist]'s image to this view, also updating the content description.
*
* @param artist the [Artist] to bind.
*/
fun bind(artist: Artist) = bindImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
fun bind(artist: Artist) = bind(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
/**
* Bind an [Genre]'s image to this view, also updating the content description.
*
* @param genre the [Genre] to bind.
*/
fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
fun bind(genre: Genre) = bind(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
/**
* Internally bind a [Music]'s image to this view.
* Bind a [Playlist]'s image to this view, also updating the content description.
*
* @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].
* @param playlist The [Playlist] to bind.
* @param songs [Song]s that can override the playlist image if it needs to differ for any
* reason.
*/
private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
fun bind(playlist: Playlist, songs: List<Song>? = null) =
if (songs != null) {
bind(
songs,
context.getString(R.string.desc_playlist_image, playlist.name.resolve(context)),
R.drawable.ic_playlist_24)
} else {
bind(playlist, R.drawable.ic_playlist_24, R.string.desc_playlist_image)
}
private fun bind(parent: MusicParent, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
bind(parent.songs, context.getString(descRes, parent.name.resolve(context)), errorRes)
}
private fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) {
val request =
ImageRequest.Builder(context)
.data(music)
.data(songs)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
.transformations(SquareFrameTransform.INSTANCE)
.target(this)
@ -142,8 +151,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Dispose of any previous image request and load a new image.
CoilUtils.dispose(this)
imageLoader.enqueue(request)
// Update the content description to the specified resource.
contentDescription = context.getString(descRes, music.resolveName(context))
contentDescription = desc
}
/**

View file

@ -18,153 +18,31 @@
package org.oxycblt.auxio.image.extractor
import android.content.Context
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.key.Keyer
import coil.request.Options
import coil.size.Size
import javax.inject.Inject
import kotlin.math.min
import okio.buffer
import okio.source
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.Song
import org.oxycblt.auxio.music.*
/**
* A [Keyer] implementation for [Music] data.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicKeyer : Keyer<Music> {
override fun key(data: Music, options: Options) =
if (data is Song) {
// Group up song covers with album covers for better caching
data.album.uid.toString()
} else {
data.uid.toString()
}
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
Keyer<List<Song>> {
override fun key(data: List<Song>, options: Options) =
"${coverExtractor.computeAlbumOrdering(data).hashCode()}"
}
/**
* Generic [Fetcher] for [Album] covers. Works with both [Album] and [Song]. Use [SongFactory] or
* [AlbumFactory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumCoverFetcher
class SongCoverFetcher
private constructor(
private val context: Context,
private val extractor: CoverExtractor,
private val album: Album
) : Fetcher {
override suspend fun fetch(): FetchResult? =
extractor.extract(album)?.run {
SourceResult(
source = ImageSource(source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
/** A [Fetcher.Factory] implementation that works with [Song]s. */
class SongFactory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Song> {
override fun create(data: Song, options: Options, imageLoader: ImageLoader) =
AlbumCoverFetcher(options.context, coverExtractor, data.album)
}
/** A [Fetcher.Factory] implementation that works with [Album]s. */
class AlbumFactory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Album> {
override fun create(data: Album, options: Options, imageLoader: ImageLoader) =
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 extractor: CoverExtractor,
private val songs: List<Song>,
private val size: Size,
private val artist: Artist
private val coverExtractor: CoverExtractor,
) : 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 -> extractor.extract(album) }
return Images.createMosaic(context, results, size)
}
override suspend fun fetch() = coverExtractor.extract(songs, size)
/** [Fetcher.Factory] implementation. */
class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Artist> {
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
ArtistImageFetcher(options.context, extractor, options.size, data)
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<List<Song>> {
override fun create(data: List<Song>, options: Options, imageLoader: ImageLoader) =
SongCoverFetcher(data, options.size, coverExtractor)
}
}
/**
* [Fetcher] for [Genre] images. Use [Factory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreImageFetcher
private constructor(
private val context: Context,
private val extractor: CoverExtractor,
private val size: Size,
private val genre: Genre
) : Fetcher {
override suspend fun fetch(): FetchResult? {
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 extractor: CoverExtractor) :
Fetcher.Factory<Genre> {
override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
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].
* @return A new list of at most N non-null [R] items.
*/
private inline fun <T : Any, R : Any> Collection<T>.mapAtMostNotNull(
n: Int,
transform: (T) -> R?
): List<R> {
val until = min(size, n)
val out = mutableListOf<R>()
for (item in this) {
if (out.size >= until) {
break
}
// Still have more data we can transform.
transform(item)?.let(out::add)
}
return out
}

View file

@ -19,13 +19,26 @@
package org.oxycblt.auxio.image.extractor
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
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 android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame
import androidx.media3.extractor.metadata.id3.ApicFrame
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.SourceResult
import coil.size.Dimension
import coil.size.Size
import coil.size.pxOrElse
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream
import java.io.InputStream
@ -33,9 +46,12 @@ import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -46,8 +62,28 @@ constructor(
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
suspend fun extract(songs: List<Song>, size: Size): FetchResult? {
val albums = computeAlbumOrdering(songs)
val streams = mutableListOf<InputStream>()
for (album in albums) {
openInputStream(album)?.let(streams::add)
if (streams.size == 4) {
return createMosaic(streams, size)
}
}
suspend fun extract(album: Album): InputStream? =
return streams.firstOrNull()?.let { stream ->
SourceResult(
source = ImageSource(stream.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
}
fun computeAlbumOrdering(songs: List<Song>) =
songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key }
private suspend fun openInputStream(album: Album): InputStream? =
try {
when (imageSettings.coverMode) {
CoverMode.OFF -> null
@ -123,8 +159,61 @@ constructor(
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) }
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap =
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(mosaicBitmap)
var x = 0
var y = 0
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
// and place it on a corner of the canvas.
for (stream in streams) {
if (y == mosaicSize.height) {
break
}
// Run the bitmap through a transform to reflect the configuration of other images.
val bitmap =
SquareFrameTransform.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
if (x == mosaicSize.width) {
x = 0
y += bitmap.height
}
}
// It's way easier to map this into a drawable then try to serialize it into an
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
// load low-res mosaics into high-res ImageViews.
return DrawableResult(
drawable = mosaicBitmap.toDrawable(context.resources),
isSampled = true,
dataSource = DataSource.DISK)
}
/**
* Get an image dimension suitable to create a mosaic with.
*
* @return A pixel dimension derived from the given [Dimension] that will always be even,
* allowing it to be sub-divided.
*/
private fun Dimension.mosaicSize(): Int {
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 Auxio Project
* ExtractorModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 coil.ImageLoader
import coil.request.CachePolicy
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class ExtractorModule {
@Singleton
@Provides
fun imageLoader(
@ApplicationContext context: Context,
songKeyer: SongKeyer,
songFactory: SongCoverFetcher.Factory
) =
ImageLoader.Builder(context)
.components {
// Add fetchers for Music components to make them usable with ImageRequest
add(songKeyer)
add(songFactory)
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())
// Not downloading anything, so no disk-caching
.diskCachePolicy(CachePolicy.DISABLED)
.build()
}

View file

@ -1,118 +0,0 @@
/*
* 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
* 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.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.SourceResult
import coil.size.Dimension
import coil.size.Size
import coil.size.pxOrElse
import java.io.InputStream
import okio.buffer
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.
*/
suspend fun createMosaic(
context: Context,
streams: List<InputStream>,
size: Size
): FetchResult? {
if (streams.size < 4) {
return streams.firstOrNull()?.let { stream ->
SourceResult(
source = ImageSource(stream.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
}
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap =
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(mosaicBitmap)
var x = 0
var y = 0
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
// and place it on a corner of the canvas.
for (stream in streams) {
if (y == mosaicSize.height) {
break
}
// Run the bitmap through a transform to reflect the configuration of other images.
val bitmap =
SquareFrameTransform.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
if (x == mosaicSize.width) {
x = 0
y += bitmap.height
}
}
// It's way easier to map this into a drawable then try to serialize it into an
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
// load low-res mosaics into high-res ImageViews.
return DrawableResult(
drawable = mosaicBitmap.toDrawable(context.resources),
isSampled = true,
dataSource = DataSource.DISK)
}
/**
* Get an image dimension suitable to create a mosaic with.
*
* @return A pixel dimension derived from the given [Dimension] that will always be even,
* allowing it to be sub-divided.
*/
private fun Dimension.mosaicSize(): Int {
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
}

View file

@ -40,3 +40,11 @@ interface Header : Item {
* @author Alexander Capehart (OxygenCobalt)
*/
data class BasicHeader(@StringRes override val titleRes: Int) : Header
/**
* A divider decoration used to delimit groups of data.
*
* @param anchor The [Header] this divider should be next to in a list. Used as a way to preserve
* divider continuity during list updates.
*/
data class Divider(val anchor: Header?) : Item

View file

@ -22,7 +22,6 @@ import android.view.MenuItem
import android.view.View
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.internal.view.SupportMenu
import androidx.core.view.MenuCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
@ -30,8 +29,8 @@ import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.selection.SelectionFragment
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.navigation.MainNavigationAction
import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
@ -59,7 +58,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
*/
abstract fun onRealClick(item: T)
override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) {
final override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) {
if (selectionModel.selected.value.isNotEmpty()) {
// Map clicking an item to selecting an item when items are already selected.
selectionModel.select(item)
@ -69,7 +68,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
}
}
override fun onSelect(item: T) {
final override fun onSelect(item: T) {
selectionModel.select(item)
}
@ -82,7 +81,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param song The [Song] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) {
logD("Launching new song menu: ${song.rawName}")
logD("Launching new song menu: ${song.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@ -100,6 +99,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
R.id.action_go_album -> {
navModel.exploreNavigateTo(song.album)
}
R.id.action_playlist_add -> {
musicModel.addToPlaylist(song)
}
R.id.action_song_detail -> {
navModel.mainNavigateTo(
MainNavigationAction.Directions(
@ -121,7 +123,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param album The [Album] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) {
logD("Launching new album menu: ${album.rawName}")
logD("Launching new album menu: ${album.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@ -142,6 +144,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
R.id.action_go_artist -> {
navModel.exploreNavigateToParentArtist(album)
}
R.id.action_playlist_add -> {
musicModel.addToPlaylist(album)
}
else -> {
error("Unexpected menu item selected")
}
@ -158,7 +163,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param artist The [Artist] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) {
logD("Launching new artist menu: ${artist.rawName}")
logD("Launching new artist menu: ${artist.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@ -176,6 +181,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
playbackModel.addToQueue(artist)
requireContext().showToast(R.string.lng_queue_added)
}
R.id.action_playlist_add -> {
musicModel.addToPlaylist(artist)
}
else -> {
error("Unexpected menu item selected")
}
@ -192,7 +200,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
* @param genre The [Genre] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) {
logD("Launching new genre menu: ${genre.rawName}")
logD("Launching new genre menu: ${genre.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@ -210,6 +218,49 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
playbackModel.addToQueue(genre)
requireContext().showToast(R.string.lng_queue_added)
}
R.id.action_playlist_add -> {
musicModel.addToPlaylist(genre)
}
else -> {
error("Unexpected menu item selected")
}
}
}
}
/**
* Opens a menu in the context of a [Playlist]. 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 playlist The [Playlist] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) {
logD("Launching new playlist menu: ${playlist.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
R.id.action_play -> {
playbackModel.play(playlist)
}
R.id.action_shuffle -> {
playbackModel.shuffle(playlist)
}
R.id.action_play_next -> {
playbackModel.playNext(playlist)
requireContext().showToast(R.string.lng_queue_added)
}
R.id.action_queue_add -> {
playbackModel.addToQueue(playlist)
requireContext().showToast(R.string.lng_queue_added)
}
R.id.action_rename -> {
musicModel.renamePlaylist(playlist)
}
R.id.action_delete -> {
musicModel.deletePlaylist(playlist)
}
else -> {
error("Unexpected menu item selected")
}
@ -247,7 +298,6 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
currentMenu =
PopupMenu(requireContext(), anchor).apply {
inflate(menuRes)
logD(menu is SupportMenu)
MenuCompat.setGroupDividerEnabled(menu, true)
block()
setOnDismissListener { currentMenu = null }

View file

@ -50,11 +50,11 @@ interface ClickableListListener<in T> {
}
/**
* An extension of [ClickableListListener] that enables list editing functionality.
* A listener for lists that can be edited.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface EditableListListener<in T> : ClickableListListener<T> {
interface EditableListListener {
/**
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
*
@ -62,6 +62,29 @@ interface EditableListListener<in T> : ClickableListListener<T> {
*/
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
/**
* Binds this instance to a list item.
*
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param dragHandle A touchable [View]. Any drag on this view will start a drag event.
*/
fun bind(viewHolder: RecyclerView.ViewHolder, dragHandle: View) {
dragHandle.setOnTouchListener { _, motionEvent ->
dragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
onPickUp(viewHolder)
true
} else false
}
}
}
/**
* A listener for lists that can be clicked and edited at the same time.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface EditClickListListener<in T> : ClickableListListener<T>, EditableListListener {
/**
* Binds this instance to a list item.
*
@ -78,13 +101,7 @@ interface EditableListListener<in T> : ClickableListListener<T> {
dragHandle: View
) {
bind(item, viewHolder, bodyView)
dragHandle.setOnTouchListener { _, motionEvent ->
dragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
onPickUp(viewHolder)
true
} else false
}
bind(viewHolder, dragHandle)
}
}

View file

@ -24,8 +24,8 @@ import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort.Mode
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
/**
* A sorting method.
@ -102,39 +102,40 @@ data class Sort(val mode: Mode, val direction: Direction) {
}
/**
* Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
* Sort a list of [Playlist]s.
*
* @param songs The [Song]s to sort.
* @param playlists The list of [Playlist]s.
* @return A new list of [Playlist]s sorted by this [Sort]'s configuration
*/
fun <T : Playlist> playlists(playlists: Collection<T>): List<T> {
val mutable = playlists.toMutableList()
playlistsInPlace(mutable)
return mutable
}
private fun songsInPlace(songs: MutableList<out Song>) {
songs.sortWith(mode.getSongComparator(direction))
val comparator = mode.getSongComparator(direction) ?: return
songs.sortWith(comparator)
}
/**
* 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>) {
albums.sortWith(mode.getAlbumComparator(direction))
val comparator = mode.getAlbumComparator(direction) ?: return
albums.sortWith(comparator)
}
/**
* 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>) {
artists.sortWith(mode.getArtistComparator(direction))
val comparator = mode.getArtistComparator(direction) ?: return
artists.sortWith(comparator)
}
/**
* 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>) {
genres.sortWith(mode.getGenreComparator(direction))
val comparator = mode.getGenreComparator(direction) ?: return
genres.sortWith(comparator)
}
private fun playlistsInPlace(playlists: MutableList<out Playlist>) {
val comparator = mode.getPlaylistComparator(direction) ?: return
playlists.sortWith(comparator)
}
/**
@ -154,58 +155,63 @@ data class Sort(val mode: Mode, val direction: Direction) {
}
/** Describes the type of data to sort with. */
sealed class Mode {
sealed interface Mode {
/** The integer representation of this sort mode. */
abstract val intCode: Int
val intCode: Int
/** The item ID of this sort mode in menu resources. */
abstract val itemId: Int
val itemId: Int
/**
* 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].
* @return A [Comparator] that can be used to sort a [Song] list according to this [Mode],
* or null to not sort at all.
*/
open fun getSongComparator(direction: Direction): Comparator<Song> {
throw UnsupportedOperationException()
}
fun getSongComparator(direction: Direction): Comparator<Song>? = null
/**
* 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].
* @return A [Comparator] that can be used to sort a [Album] list according to this [Mode],
* or null to not sort at all.
*/
open fun getAlbumComparator(direction: Direction): Comparator<Album> {
throw UnsupportedOperationException()
}
fun getAlbumComparator(direction: Direction): Comparator<Album>? = null
/**
* 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].
* or null to not sort at all.
*/
open fun getArtistComparator(direction: Direction): Comparator<Artist> {
throw UnsupportedOperationException()
}
fun getArtistComparator(direction: Direction): Comparator<Artist>? = null
/**
* 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].
* or null to not sort at all.
*/
open fun getGenreComparator(direction: Direction): Comparator<Genre> {
throw UnsupportedOperationException()
}
fun getGenreComparator(direction: Direction): Comparator<Genre>? = null
/**
* Return a [Comparator] that sorts [Playlist]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].
* or null to not sort at all.
*/
fun getPlaylistComparator(direction: Direction): Comparator<Playlist>? = null
/**
* Sort by the item's name.
*
* @see Music.sortName
* @see Music.name
*/
object ByName : Mode() {
object ByName : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_NAME
@ -223,14 +229,17 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun getGenreComparator(direction: Direction) =
compareByDynamic(direction, BasicComparator.GENRE)
override fun getPlaylistComparator(direction: Direction) =
compareByDynamic(direction, BasicComparator.PLAYLIST)
}
/**
* Sort by the [Album] of an item. Only available for [Song]s.
*
* @see Album.collationKey
* @see Album.name
*/
object ByAlbum : Mode() {
object ByAlbum : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_ALBUM
@ -248,9 +257,9 @@ 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.sortName
* @see Artist.name
*/
object ByArtist : Mode() {
object ByArtist : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_ARTIST
@ -279,7 +288,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @see Song.date
* @see Album.dates
*/
object ByDate : Mode() {
object ByDate : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_YEAR
@ -301,7 +310,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
}
/** Sort by the duration of an item. */
object ByDuration : Mode() {
object ByDuration : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_DURATION
@ -324,6 +333,11 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun getGenreComparator(direction: Direction): Comparator<Genre> =
MultiComparator(
compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.GENRE))
override fun getPlaylistComparator(direction: Direction): Comparator<Playlist> =
MultiComparator(
compareByDynamic(direction) { it.durationMs },
compareBy(BasicComparator.PLAYLIST))
}
/**
@ -331,7 +345,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
*
* @see MusicParent.songs
*/
object ByCount : Mode() {
object ByCount : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_COUNT
@ -350,6 +364,11 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun getGenreComparator(direction: Direction): Comparator<Genre> =
MultiComparator(
compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.GENRE))
override fun getPlaylistComparator(direction: Direction): Comparator<Playlist> =
MultiComparator(
compareByDynamic(direction) { it.songs.size },
compareBy(BasicComparator.PLAYLIST))
}
/**
@ -357,7 +376,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
*
* @see Song.disc
*/
object ByDisc : Mode() {
object ByDisc : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_DISC
@ -376,7 +395,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
*
* @see Song.track
*/
object ByTrack : Mode() {
object ByTrack : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_TRACK
@ -396,7 +415,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @see Song.dateAdded
* @see Album.dates
*/
object ByDateAdded : Mode() {
object ByDateAdded : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_DATE_ADDED
@ -413,176 +432,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
compareBy(BasicComparator.ALBUM))
}
/**
* Utility function to create a [Comparator] in a dynamic way determined by [direction].
*
* @param direction The [Direction] to sort in.
* @see compareBy
* @see compareByDescending
*/
protected inline fun <T : Music, K : Comparable<K>> compareByDynamic(
direction: Direction,
crossinline selector: (T) -> K
) =
when (direction) {
Direction.ASCENDING -> compareBy(selector)
Direction.DESCENDING -> compareByDescending(selector)
}
/**
* 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.
* @see compareBy
* @see compareByDescending
*/
protected fun <T : Music> compareByDynamic(
direction: Direction,
comparator: Comparator<in T>
): Comparator<T> = compareByDynamic(direction, comparator) { it }
/**
* 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.
* @return A new [Comparator] with the specified configuration.
* @see compareBy
* @see compareByDescending
*/
protected inline fun <T : Music, K> compareByDynamic(
direction: Direction,
comparator: Comparator<in K>,
crossinline selector: (T) -> K
) =
when (direction) {
Direction.ASCENDING -> compareBy(comparator, selector)
Direction.DESCENDING -> compareByDescending(comparator, selector)
}
/**
* 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
*/
protected fun <T : Music> compareBy(comparator: Comparator<T>): Comparator<T> =
compareBy(comparator) { it }
/**
* 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.
*/
private class MultiComparator<T>(vararg comparators: Comparator<T>) : Comparator<T> {
private val _comparators = comparators
override fun compare(a: T?, b: T?): Int {
for (comparator in _comparators) {
val result = comparator.compare(a, b)
if (result != 0) {
return result
}
}
return 0
}
}
/**
* 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>> {
override fun compare(a: List<T>, b: List<T>): Int {
for (i in 0 until max(a.size, b.size)) {
val ai = a.getOrNull(i)
val bi = b.getOrNull(i)
when {
ai != null && bi != null -> {
val result = inner.compare(ai, bi)
if (result != 0) {
return result
}
}
ai == null && bi != null -> return -1 // a < b
ai == null && bi == null -> return 0 // a = b
else -> return 1 // a < b
}
}
return 0
}
companion object {
/** A re-usable configured for [Artist]s.. */
val ARTISTS: Comparator<List<Artist>> = ListComparator(BasicComparator.ARTIST)
}
}
/**
* 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.sortName
val bKey = b.sortName
return when {
aKey != null && bKey != null -> aKey.compareTo(bKey)
aKey == null && bKey != null -> -1 // a < b
aKey == null && bKey == null -> 0 // a = b
else -> 1 // a < b
}
}
companion object {
/** A re-usable instance configured for [Song]s. */
val SONG: Comparator<Song> = BasicComparator()
/** A re-usable instance configured for [Album]s. */
val ALBUM: Comparator<Album> = BasicComparator()
/** A re-usable instance configured for [Artist]s. */
val ARTIST: Comparator<Artist> = BasicComparator()
/** A re-usable instance configured for [Genre]s. */
val GENRE: Comparator<Genre> = BasicComparator()
}
}
/**
* A [Comparator] that compares two possibly null values. Values will be considered lesser
* if they are null, and greater if they are non-null.
*/
private class NullableComparator<T : Comparable<T>> private constructor() : Comparator<T?> {
override fun compare(a: T?, b: T?) =
when {
a != null && b != null -> a.compareTo(b)
a == null && b != null -> -1 // a < b
a == null && b == null -> 0 // a = b
else -> 1 // a < b
}
companion object {
/** A re-usable instance configured for [Int]s. */
val INT = NullableComparator<Int>()
/** A re-usable instance configured for [Long]s. */
val LONG = NullableComparator<Long>()
/** A re-usable instance configured for [Disc]s */
val DISC = NullableComparator<Disc>()
/** A re-usable instance configured for [Date.Range]s. */
val DATE_RANGE = NullableComparator<Date.Range>()
}
}
companion object {
/**
* Convert a [Mode] integer representation into an instance.
@ -652,3 +501,166 @@ 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 [Sort.Direction] to sort in.
* @see compareBy
* @see compareByDescending
*/
private inline fun <T : Music, K : Comparable<K>> compareByDynamic(
direction: Sort.Direction,
crossinline selector: (T) -> K
) =
when (direction) {
Sort.Direction.ASCENDING -> compareBy(selector)
Sort.Direction.DESCENDING -> compareByDescending(selector)
}
/**
* Utility function to create a [Comparator] in a dynamic way determined by [direction]
*
* @param direction The [Sort.Direction] to sort in.
* @param comparator A [Comparator] to wrap.
* @return A new [Comparator] with the specified configuration.
* @see compareBy
* @see compareByDescending
*/
private fun <T : Music> compareByDynamic(
direction: Sort.Direction,
comparator: Comparator<in T>
): Comparator<T> = compareByDynamic(direction, comparator) { it }
/**
* Utility function to create a [Comparator] a dynamic way determined by [direction]
*
* @param direction The [Sort.Direction] to sort in.
* @param comparator A [Comparator] to wrap.
* @param selector Called to obtain a specific attribute to sort by.
* @return A new [Comparator] with the specified configuration.
* @see compareBy
* @see compareByDescending
*/
private inline fun <T : Music, K> compareByDynamic(
direction: Sort.Direction,
comparator: Comparator<in K>,
crossinline selector: (T) -> K
) =
when (direction) {
Sort.Direction.ASCENDING -> compareBy(comparator, selector)
Sort.Direction.DESCENDING -> compareByDescending(comparator, selector)
}
/**
* 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
*/
private fun <T : Music> compareBy(comparator: Comparator<T>): Comparator<T> =
compareBy(comparator) { it }
/**
* 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.
*/
private class MultiComparator<T>(vararg comparators: Comparator<T>) : Comparator<T> {
private val _comparators = comparators
override fun compare(a: T?, b: T?): Int {
for (comparator in _comparators) {
val result = comparator.compare(a, b)
if (result != 0) {
return result
}
}
return 0
}
}
/**
* 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>> {
override fun compare(a: List<T>, b: List<T>): Int {
for (i in 0 until max(a.size, b.size)) {
val ai = a.getOrNull(i)
val bi = b.getOrNull(i)
when {
ai != null && bi != null -> {
val result = inner.compare(ai, bi)
if (result != 0) {
return result
}
}
ai == null && bi != null -> return -1 // a < b
ai == null && bi == null -> return 0 // a = b
else -> return 1 // a < b
}
}
return 0
}
companion object {
/** A re-usable configured for [Artist]s.. */
val ARTISTS: Comparator<List<Artist>> = ListComparator(BasicComparator.ARTIST)
}
}
/**
* A [Comparator] that compares abstract [Music] values. Internally, this is similar to
* [NullableComparator], however comparing [Music.name] instead of [Comparable].
*
* @see NullableComparator
* @see Music.name
*/
private class BasicComparator<T : Music> private constructor() : Comparator<T> {
override fun compare(a: T, b: T) = a.name.compareTo(b.name)
companion object {
/** A re-usable instance configured for [Song]s. */
val SONG: Comparator<Song> = BasicComparator()
/** A re-usable instance configured for [Album]s. */
val ALBUM: Comparator<Album> = BasicComparator()
/** A re-usable instance configured for [Artist]s. */
val ARTIST: Comparator<Artist> = BasicComparator()
/** A re-usable instance configured for [Genre]s. */
val GENRE: Comparator<Genre> = BasicComparator()
/** A re-usable instance configured for [Playlist]s. */
val PLAYLIST: Comparator<Playlist> = BasicComparator()
}
}
/**
* A [Comparator] that compares two possibly null values. Values will be considered lesser if they
* are null, and greater if they are non-null.
*/
private class NullableComparator<T : Comparable<T>> private constructor() : Comparator<T?> {
override fun compare(a: T?, b: T?) =
when {
a != null && b != null -> a.compareTo(b)
a == null && b != null -> -1 // a < b
a == null && b == null -> 0 // a = b
else -> 1 // a < b
}
companion object {
/** A re-usable instance configured for [Int]s. */
val INT = NullableComparator<Int>()
/** A re-usable instance configured for [Long]s. */
val LONG = NullableComparator<Long>()
/** A re-usable instance configured for [Disc]s */
val DISC = NullableComparator<Disc>()
/** A re-usable instance configured for [Date.Range]s. */
val DATE_RANGE = NullableComparator<Date.Range>()
}
}

View file

@ -62,16 +62,16 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class UpdateInstructions {
sealed interface UpdateInstructions {
/** Use an asynchronous diff. Useful for unpredictable updates, but looks chaotic and janky. */
object Diff : UpdateInstructions()
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()
data class Replace(val from: Int) : UpdateInstructions
/**
* Add a new set of items.
@ -79,7 +79,7 @@ sealed class UpdateInstructions {
* @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()
data class Add(val at: Int, val size: Int) : UpdateInstructions
/**
* Move one item to another location.
@ -87,14 +87,15 @@ sealed class UpdateInstructions {
* @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()
data class Move(val from: Int, val to: Int) : UpdateInstructions
/**
* Remove an item.
*
* @param at The location that the item should be removed from.
* @param size The amount of items to add.
*/
data class Remove(val at: Int) : UpdateInstructions()
data class Remove(val at: Int, val size: Int) : UpdateInstructions
}
/**
@ -147,7 +148,7 @@ private class FlexibleListDiffer<T>(
}
is UpdateInstructions.Remove -> {
currentList = newList
updateCallback.onRemoved(instructions.at, 1)
updateCallback.onRemoved(instructions.at, instructions.size)
callback?.invoke()
}
is UpdateInstructions.Diff,

View file

@ -23,14 +23,12 @@ import android.util.AttributeSet
import android.view.WindowInsets
import androidx.annotation.AttrRes
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* A [RecyclerView] with a few QoL extensions, such as:
* - Automatic edge-to-edge support
* - Adapter-based [SpanSizeLookup] implementation
* - Automatic [setHasFixedSize] setup
*
* @author Alexander Capehart (OxygenCobalt)
@ -47,7 +45,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Auxio's non-dialog RecyclerViews never change their size based on adapter contents,
// so we can enable fixed-size optimizations.
setHasFixedSize(true)
addItemDecoration(HeaderItemDecoration(context))
}
final override fun setHasFixedSize(hasFixedSize: Boolean) {
@ -65,36 +62,4 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
return insets
}
override fun setAdapter(adapter: Adapter<*>?) {
super.setAdapter(adapter)
if (adapter is SpanSizeLookup) {
// This adapter has support for special span sizes, hook it up to the
// GridLayoutManager.
val glm = (layoutManager as GridLayoutManager)
val fullWidthSpanCount = glm.spanCount
glm.spanSizeLookup =
object : GridLayoutManager.SpanSizeLookup() {
// Using the adapter implementation, if the adapter specifies that
// an item is full width, it will take up all of the spans, using a
// single span otherwise.
override fun getSpanSize(position: Int) =
if (adapter.isItemFullWidth(position)) fullWidthSpanCount else 1
}
}
}
/** A [RecyclerView.Adapter]-specific hook to control divider decoration visibility. */
/** An [RecyclerView.Adapter]-specific hook to [GridLayoutManager.SpanSizeLookup]. */
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.
*/
fun isItemFullWidth(position: Int): Boolean
}
}

View file

@ -1,70 +0,0 @@
/*
* 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
* 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.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.FlexibleListAdapter
/**
* A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly
* separate content with headers.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class HeaderItemDecoration
@JvmOverloads
constructor(
context: Context,
attributeSet: AttributeSet? = null,
defStyleAttr: Int = R.attr.materialDividerStyle,
orientation: Int = LinearLayoutManager.VERTICAL
) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) {
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 FlexibleListAdapter<*, *>).getItem(position) is Header
} catch (e: ClassCastException) {
false
} catch (e: IndexOutOfBoundsException) {
false
}
}

View file

@ -0,0 +1,152 @@
/*
* Copyright (c) 2021 Auxio Project
* MaterialDragCallback.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.recycler
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.logD
/**
* A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in editable UIs,
* such as an animation when lifting items. Note that this requires a [ViewHolder] implementation in
* order to function.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
private var shouldLift = true
final override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) =
if (viewHolder is ViewHolder && viewHolder.enabled) {
makeFlag(
ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or
makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START)
} else {
0
}
final override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
val holder = viewHolder as ViewHolder
// Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure
// this is only done once when the item is initially picked up.
// TODO: I think this is possible to improve with a raw ValueAnimator.
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
logD("Lifting item")
val bg = holder.background
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
holder.root
.animate()
.translationZ(elevation)
.setDuration(
recyclerView.context.getInteger(R.integer.anim_fade_exit_duration).toLong())
.setUpdateListener {
bg.alpha = ((holder.root.translationZ / elevation) * 255).toInt()
}
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
shouldLift = false
}
// We show a background with a delete icon behind the item each time one is swiped
// away. To avoid working with canvas, this is simply placed behind the body.
// That comes with a couple of problems, however. For one, the background view will always
// lag behind the body view, resulting in a noticeable pixel offset when dragging. To fix
// this, we make this a separate view and make this view invisible whenever the item is
// not being swiped. This issue is also the reason why the background is not merged with
// the FrameLayout within the item.
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
holder.delete.isInvisible = dX == 0f
}
// Update other translations. We do not call the default implementation, so we must do
// this ourselves.
holder.body.translationX = dX
holder.root.translationY = dY
}
final override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
// When an elevated item is cleared, we reset the elevation using another animation.
val holder = viewHolder as ViewHolder
// This function can be called multiple times, so only start the animation when the view's
// translationZ is already non-zero.
if (holder.root.translationZ != 0f) {
logD("Dropping item")
val bg = holder.background
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
holder.root
.animate()
.translationZ(0f)
.setDuration(
recyclerView.context.getInteger(R.integer.anim_fade_exit_duration).toLong())
.setUpdateListener {
bg.alpha = ((holder.root.translationZ / elevation) * 255).toInt()
}
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
}
shouldLift = true
// Reset translations. We do not call the default implementation, so we must do
// this ourselves.
holder.body.translationX = 0f
holder.root.translationY = 0f
}
// Long-press events are too buggy, only allow dragging with the handle.
final override fun isLongPressDragEnabled() = false
/** Required [RecyclerView.ViewHolder] implementation that exposes the following. */
interface ViewHolder {
/** Whether this [ViewHolder] can be moved right now. */
val enabled: Boolean
/** The root view containing the delete scrim and information. */
val root: View
/** The body view containing music information. */
val body: View
/** The scrim view showing the delete icon. Should be behind [body]. */
val delete: View
/** The drawable of the [body] background that can be elevated. */
val background: Drawable
}
}

View file

@ -20,12 +20,14 @@ package org.oxycblt.auxio.list.recycler
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDivider
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
@ -51,7 +53,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
fun bind(song: Song, listener: SelectableListListener<Song>) {
listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.artists.resolveNames(binding.context)
}
@ -80,8 +82,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
val DIFF_CALLBACK =
object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName &&
oldItem.artists.areRawNamesTheSame(newItem.artists)
oldItem.name == newItem.name && oldItem.artists.areNamesTheSame(newItem.artists)
}
}
}
@ -102,7 +103,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
fun bind(album: Album, listener: SelectableListListener<Album>) {
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text = album.artists.resolveNames(binding.context)
}
@ -131,8 +132,8 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
val DIFF_CALLBACK =
object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
oldItem.artists.areRawNamesTheSame(newItem.artists) &&
oldItem.name == newItem.name &&
oldItem.artists.areNamesTheSame(newItem.artists) &&
oldItem.releaseType == newItem.releaseType
}
}
@ -154,17 +155,16 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
fun bind(artist: Artist, listener: SelectableListListener<Artist>) {
listener.bind(artist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(artist)
binding.parentName.text = artist.resolveName(binding.context)
binding.parentName.text = artist.name.resolve(binding.context)
binding.parentInfo.text =
if (artist.songs.isNotEmpty()) {
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))
} else {
// Artist has no songs, only display an album count.
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
}
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
if (artist.songs.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
} else {
binding.context.getString(R.string.def_song_count)
})
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@ -193,7 +193,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
val DIFF_CALLBACK =
object : SimpleDiffCallback<Artist>() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.rawName == newItem.rawName &&
oldItem.name == newItem.name &&
oldItem.albums.size == newItem.albums.size &&
oldItem.songs.size == newItem.songs.size
}
@ -216,7 +216,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
fun bind(genre: Genre, listener: SelectableListListener<Genre>) {
listener.bind(genre, this, menuButton = binding.parentMenu)
binding.parentImage.bind(genre)
binding.parentName.text = genre.resolveName(binding.context)
binding.parentName.text = genre.name.resolve(binding.context)
binding.parentInfo.text =
binding.context.getString(
R.string.fmt_two,
@ -248,8 +248,66 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Genre>() {
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.name == newItem.name &&
oldItem.artists.size == newItem.artists.size &&
oldItem.songs.size == newItem.songs.size
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [Playlist]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistViewHolder private constructor(private val binding: ItemParentBinding) :
SelectionIndicatorAdapter.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param playlist The new [Playlist] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(playlist: Playlist, listener: SelectableListListener<Playlist>) {
listener.bind(playlist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(playlist)
binding.parentName.text = playlist.name.resolve(binding.context)
binding.parentInfo.text =
if (playlist.songs.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size)
} else {
binding.context.getString(R.string.def_song_count)
}
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying
}
override fun updateSelectionIndicator(isSelected: Boolean) {
binding.root.isActivated = isSelected
}
companion object {
/** Unique ID for this ViewHolder type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_PLAYLIST
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
PlaylistViewHolder(ItemParentBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Playlist>() {
override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist) =
oldItem.name == newItem.name && oldItem.songs.size == newItem.songs.size
}
}
}
@ -287,10 +345,37 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<BasicHeader>() {
override fun areContentsTheSame(
oldItem: BasicHeader,
newItem: BasicHeader
): Boolean = oldItem.titleRes == newItem.titleRes
override fun areContentsTheSame(oldItem: BasicHeader, newItem: BasicHeader) =
oldItem.titleRes == newItem.titleRes
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [Divider]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class DividerViewHolder private constructor(divider: MaterialDivider) :
RecyclerView.ViewHolder(divider) {
companion object {
/** Unique ID for this ViewHolder type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DIVIDER
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) = DividerViewHolder(MaterialDivider(parent.context))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Divider>() {
override fun areContentsTheSame(oldItem: Divider, newItem: Divider) =
oldItem.anchor == newItem.anchor
}
}
}

View file

@ -23,6 +23,7 @@ import android.view.MenuItem
import androidx.appcompat.widget.Toolbar
import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.showToast
@ -35,22 +36,16 @@ import org.oxycblt.auxio.util.showToast
abstract class SelectionFragment<VB : ViewBinding> :
ViewBindingFragment<VB>(), Toolbar.OnMenuItemClickListener {
protected abstract val selectionModel: SelectionViewModel
protected abstract val musicModel: MusicViewModel
protected abstract val playbackModel: PlaybackViewModel
/**
* 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.
*/
open fun getSelectionToolbar(binding: VB): SelectionToolbarOverlay? = null
open fun getSelectionToolbar(binding: VB): Toolbar? = null
override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
getSelectionToolbar(binding)?.apply {
// Add cancel and menu item listeners to manage what occurs with the selection.
setOnSelectionCancelListener { selectionModel.consume() }
setNavigationOnClickListener { selectionModel.drop() }
setOnMenuItemClickListener(this@SelectionFragment)
}
}
@ -63,21 +58,25 @@ abstract class SelectionFragment<VB : ViewBinding> :
override fun onMenuItemClick(item: MenuItem) =
when (item.itemId) {
R.id.action_selection_play_next -> {
playbackModel.playNext(selectionModel.consume())
playbackModel.playNext(selectionModel.take())
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_selection_queue_add -> {
playbackModel.addToQueue(selectionModel.consume())
playbackModel.addToQueue(selectionModel.take())
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_selection_playlist_add -> {
musicModel.addToPlaylist(selectionModel.take())
true
}
R.id.action_selection_play -> {
playbackModel.play(selectionModel.consume())
playbackModel.play(selectionModel.take())
true
}
R.id.action_selection_shuffle -> {
playbackModel.shuffle(selectionModel.consume())
playbackModel.shuffle(selectionModel.take())
true
}
else -> false

View file

@ -1,176 +0,0 @@
/*
* 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
* 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.selection
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener
import androidx.core.view.isInvisible
import com.google.android.material.appbar.MaterialToolbar
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getInteger
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
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
private lateinit var innerToolbar: MaterialToolbar
private val selectionToolbar =
MaterialToolbar(context).apply {
setNavigationIcon(R.drawable.ic_close_24)
inflateMenu(R.menu.menu_selection_actions)
if (isInEditMode) {
isInvisible = true
}
}
private var fadeThroughAnimator: ValueAnimator? = null
override fun onFinishInflate() {
super.onFinishInflate()
// Sanity check: Avoid incorrect views from being included in this layout.
check(childCount == 1 && getChildAt(0) is MaterialToolbar) {
"SelectionToolbarOverlay Must have only one MaterialToolbar child"
}
// The inner toolbar should be the first child.
innerToolbar = getChildAt(0) as MaterialToolbar
// Selection toolbar should appear on top of the inner toolbar.
addView(selectionToolbar)
}
/**
* 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
*/
fun setOnSelectionCancelListener(listener: OnClickListener) {
selectionToolbar.setNavigationOnClickListener(listener)
}
/**
* 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
*/
fun setOnMenuItemClickListener(listener: OnMenuItemClickListener?) {
selectionToolbar.setOnMenuItemClickListener(listener)
}
/**
* 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.
*/
fun updateSelectionAmount(amount: Int): Boolean {
logD("Updating selection amount to $amount")
return if (amount > 0) {
// Only update the selected amount when it's non-zero to prevent a strange
// title text.
selectionToolbar.title = context.getString(R.string.fmt_selected, amount)
animateToolbarsVisibility(true)
} else {
animateToolbarsVisibility(false)
}
}
/**
* 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.
*/
private fun animateToolbarsVisibility(selectionVisible: Boolean): Boolean {
// TODO: Animate nicer Material Fade transitions using animators (Normal transitions
// don't work due to translation)
// Set up the target transitions for both the inner and selection toolbars.
val targetInnerAlpha: Float
val targetSelectionAlpha: Float
val targetDuration: Long
if (selectionVisible) {
targetInnerAlpha = 0f
targetSelectionAlpha = 1f
targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
targetInnerAlpha = 1f
targetSelectionAlpha = 0f
targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
if (innerToolbar.alpha == targetInnerAlpha &&
selectionToolbar.alpha == targetSelectionAlpha) {
// Nothing to do.
return false
}
if (!isLaidOut) {
// Not laid out, just change it immediately while are not shown to the user.
// This is an initialization, so we return false despite changing.
setToolbarsAlpha(targetInnerAlpha)
return false
}
if (fadeThroughAnimator != null) {
fadeThroughAnimator?.cancel()
fadeThroughAnimator = null
}
fadeThroughAnimator =
ValueAnimator.ofFloat(innerToolbar.alpha, targetInnerAlpha).apply {
duration = targetDuration
addUpdateListener { setToolbarsAlpha(it.animatedValue as Float) }
start()
}
return true
}
/**
* 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].
*/
private fun setToolbarsAlpha(innerAlpha: Float) {
innerToolbar.apply {
alpha = innerAlpha
isInvisible = innerAlpha == 0f
}
selectionToolbar.apply {
alpha = 1 - innerAlpha
isInvisible = innerAlpha == 1f
}
}
}

View file

@ -24,7 +24,6 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.model.Library
/**
* A [ViewModel] that manages the current selection.
@ -32,38 +31,42 @@ import org.oxycblt.auxio.music.model.Library
* @author Alexander Capehart (OxygenCobalt)
*/
@HiltViewModel
class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) :
ViewModel(), MusicRepository.Listener {
class SelectionViewModel
@Inject
constructor(
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings
) : ViewModel(), MusicRepository.UpdateListener {
private val _selected = MutableStateFlow(listOf<Music>())
/** the currently selected items. These are ordered in earliest selected and latest selected. */
val selected: StateFlow<List<Music>>
get() = _selected
init {
musicRepository.addListener(this)
musicRepository.addUpdateListener(this)
}
override fun onLibraryChanged(library: Library?) {
if (library == null) {
return
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return
val deviceLibrary = musicRepository.deviceLibrary ?: return
val userLibrary = musicRepository.userLibrary ?: return
// Sanitize the selection to remove items that no longer exist and thus
// won't appear in any list.
_selected.value =
_selected.value.mapNotNull {
when (it) {
is Song -> library.sanitize(it)
is Album -> library.sanitize(it)
is Artist -> library.sanitize(it)
is Genre -> library.sanitize(it)
is Song -> deviceLibrary.findSong(it.uid)
is Album -> deviceLibrary.findAlbum(it.uid)
is Artist -> deviceLibrary.findArtist(it.uid)
is Genre -> deviceLibrary.findGenre(it.uid)
is Playlist -> userLibrary.findPlaylist(it.uid)
}
}
}
override fun onCleared() {
super.onCleared()
musicRepository.removeListener(this)
musicRepository.removeUpdateListener(this)
}
/**
@ -81,9 +84,27 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR
}
/**
* Consume the current selection. This will clear any items that were selected prior.
* Clear the current selection and return it.
*
* @return The list of selected items before it was cleared.
* @return A list of [Song]s collated from each item selected.
*/
fun consume() = _selected.value.also { _selected.value = listOf() }
fun take() =
_selected.value
.flatMap {
when (it) {
is Song -> listOf(it)
is Album -> musicSettings.albumSongSort.songs(it.songs)
is Artist -> musicSettings.artistSongSort.songs(it.songs)
is Genre -> musicSettings.genreSongSort.songs(it.songs)
is Playlist -> it.songs
}
}
.also { drop() }
/**
* Clear the current selection.
*
* @return true if the prior selection was non-empty, false otherwise.
*/
fun drop() = _selected.value.isNotEmpty().also { _selected.value = listOf() }
}

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2023 Auxio Project
* Indexing.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music
import android.os.Build
/** Version-aware permission identifier for reading audio files. */
val PERMISSION_READ_AUDIO =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_AUDIO
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
/**
* Represents the current state of the music loader.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingState {
/**
* Music loading is on-going.
*
* @param progress The current progress of the music loading.
*/
data class Indexing(val progress: IndexingProgress) : IndexingState
/**
* Music loading has completed.
*
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
* will be null.
*/
data class Completed(val error: Throwable?) : IndexingState
}
/**
* Represents the current progress of music loading.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingProgress {
/** Other work is being done that does not have a defined progress. */
object Indeterminate : IndexingProgress
/**
* Songs are currently being loaded.
*
* @param current The current amount of songs loaded.
* @param total The projected total amount of songs.
*/
data class Songs(val current: Int, val total: Int) : IndexingProgress
}
/**
* Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NoAudioPermissionException : Exception() {
override val message = "Storage permissions are required to load music"
}
/**
* Thrown when no music was found.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NoMusicException : Exception() {
override val message = "No music was found on the device"
}

View file

@ -21,19 +21,19 @@ package org.oxycblt.auxio.music
import android.content.Context
import android.net.Uri
import android.os.Parcelable
import androidx.room.TypeConverter
import java.security.MessageDigest
import java.text.CollationKey
import java.text.Collator
import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.storage.Path
import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.toUuidOrNull
@ -51,35 +51,8 @@ sealed interface Music : Item {
*/
val uid: UID
/**
* The raw name of this item as it was extracted from the file-system. Will be null if the
* item's name is unknown. When showing this item in a UI, avoid this in favor of [resolveName].
*/
val rawName: String?
/**
* 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.
*/
fun resolveName(context: Context): String
/**
* The raw sort name of this item as it was extracted from the file-system. This can be used not
* only when sorting music, but also trying to locate music based on a fuzzy search by the user.
* Will be null if the item has no known sort name.
*/
val rawSortName: String?
/**
* 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 sortName: SortName?
/** The [Name] of the music item. */
val name: Name
/**
* A unique identifier for a piece of music.
@ -136,7 +109,25 @@ sealed interface Music : Item {
MUSICBRAINZ("org.musicbrainz")
}
object TypeConverters {
/** @see [Music.UID.toString] */
@TypeConverter fun fromMusicUID(uid: UID?) = uid?.toString()
/** @see [Music.UID.fromString] */
@TypeConverter fun toMusicUid(string: String?) = string?.let(UID::fromString)
}
companion object {
/**
* Creates an Auxio-style [UID] of random composition. Used if there is no
* non-subjective, unlikely-to-change metadata of the music.
*
* @param mode The analogous [MusicMode] of the item that created this [UID].
*/
fun auxio(mode: MusicMode): UID {
return UID(Format.AUXIO, mode, UUID.randomUUID())
}
/**
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
* unlikely-to-change metadata of the music.
@ -189,7 +180,7 @@ sealed interface Music : Item {
* file.
* @return A new MusicBrainz-style [UID].
*/
fun musicBrainz(mode: MusicMode, mbid: UUID): UID = UID(Format.MUSICBRAINZ, mode, mbid)
fun musicBrainz(mode: MusicMode, mbid: UUID) = UID(Format.MUSICBRAINZ, mode, mbid)
/**
* Convert a [UID]'s string representation back into a concrete [UID] instance.
@ -357,83 +348,39 @@ interface Genre : MusicParent {
}
/**
* 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".
* A playlist.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class SortName(name: String, musicSettings: MusicSettings) : Comparable<SortName> {
private val collationKey: CollationKey
val thumbString: String?
init {
var sortName = name
if (musicSettings.intelligentSorting) {
sortName = sortName.replace(LEADING_PUNCTUATION_REGEX, "")
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
}
}
// Zero pad all numbers to six digits for better sorting
sortName = sortName.replace(CONSECUTIVE_DIGITS_REGEX) { it.value.padStart(6, '0') }
}
collationKey = COLLATOR.getCollationKey(sortName)
// Keep track of a string to use in the thumb view.
// Simply show '#' for everything before 'A'
// TODO: This needs to be moved elsewhere.
thumbString =
collationKey?.run {
val thumbChar = sourceString.firstOrNull()
if (thumbChar?.isLetter() == true) thumbChar.uppercase() else "#"
}
}
override fun toString(): String = collationKey.sourceString
override fun compareTo(other: SortName) = collationKey.compareTo(other.collationKey)
override fun equals(other: Any?) = other is SortName && collationKey == other.collationKey
override fun hashCode(): Int = collationKey.hashCode()
private companion object {
val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
val LEADING_PUNCTUATION_REGEX = Regex("[\\p{Punct}+]")
val CONSECUTIVE_DIGITS_REGEX = Regex("\\d+")
}
interface Playlist : MusicParent {
/** The albums indirectly linked to by the [Song]s of this [Playlist]. */
val albums: List<Album>
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
}
/**
* Run [Music.resolveName] on each instance in the given list and concatenate them into a [String]
* in a localized manner.
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
* localized manner.
*
* @param context [Context] required
* @return A concatenated string.
*/
fun <T : Music> List<T>.resolveNames(context: Context) =
concatLocalized(context) { it.resolveName(context) }
concatLocalized(context) { it.name.resolve(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.
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
* information of an item must be compared without a context.
*
* @param other The list of items to compare to.
* @return True if they are the same (by [Music.rawName]), false otherwise.
* @return True if they are the same (by [Music.name]), false otherwise.
*/
fun <T : Music> List<T>.areRawNamesTheSame(other: List<T>): Boolean {
fun <T : Music> List<T>.areNamesTheSame(other: List<T>): Boolean {
for (i in 0 until max(size, other.size)) {
val a = getOrNull(i) ?: return false
val b = other.getOrNull(i) ?: return false
if (a.rawName != b.rawName) {
if (a.name != b.name) {
return false
}
}

View file

@ -33,7 +33,9 @@ enum class MusicMode {
/** Configure with respect to [Artist] instances. */
ARTISTS,
/** Configure with respect to [Genre] instances. */
GENRES;
GENRES,
/** Configure with respect to [Playlist] instances. */
PLAYLISTS;
/**
* The integer representation of this instance.
@ -47,6 +49,7 @@ enum class MusicMode {
ALBUMS -> IntegerTable.MUSIC_MODE_ALBUMS
ARTISTS -> IntegerTable.MUSIC_MODE_ARTISTS
GENRES -> IntegerTable.MUSIC_MODE_GENRES
PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS
}
companion object {
@ -63,6 +66,7 @@ enum class MusicMode {
IntegerTable.MUSIC_MODE_ALBUMS -> ALBUMS
IntegerTable.MUSIC_MODE_ARTISTS -> ARTISTS
IntegerTable.MUSIC_MODE_GENRES -> GENRES
IntegerTable.MUSIC_MODE_PLAYLISTS -> PLAYLISTS
else -> null
}
}

View file

@ -23,13 +23,10 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.oxycblt.auxio.music.system.Indexer
import org.oxycblt.auxio.music.system.IndexerImpl
@Module
@InstallIn(SingletonComponent::class)
interface MusicModule {
@Singleton @Binds fun repository(musicRepository: MusicRepositoryImpl): MusicRepository
@Singleton @Binds fun indexer(indexer: IndexerImpl): Indexer
@Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings
}

View file

@ -18,75 +18,452 @@
package org.oxycblt.auxio.music
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import java.util.*
import javax.inject.Inject
import org.oxycblt.auxio.music.model.Library
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import org.oxycblt.auxio.music.cache.CacheRepository
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.fs.MediaStoreExtractor
import org.oxycblt.auxio.music.metadata.TagExtractor
import org.oxycblt.auxio.music.user.MutableUserLibrary
import org.oxycblt.auxio.music.user.UserLibrary
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
/**
* A repository granting access to the music library.
* Primary manager of music information and loading.
*
* This can be used to obtain certain music items, or await changes to the music library. It is
* generally recommended to use this over Indexer to keep track of the library state, as the
* interface will be less volatile.
* Music information is loaded in-memory by this repository using an [IndexingWorker]. Changes in
* music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface MusicRepository {
/**
* The current [Library]. May be null if a [Library] has not been successfully loaded yet. This
* can change, so it's highly recommended to not access this directly and instead rely on
* [Listener].
*/
var library: Library?
/** The current music information found on the device. */
val deviceLibrary: DeviceLibrary?
/** The current user-defined music information. */
val userLibrary: UserLibrary?
/** The current state of music loading. Null if no load has occurred yet. */
val indexingState: IndexingState?
/**
* 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.
* Add an [UpdateListener] to receive updates from this instance.
*
* @param listener The [Listener] to add.
* @see Listener
* @param listener The [UpdateListener] to add.
*/
fun addListener(listener: Listener)
fun addUpdateListener(listener: UpdateListener)
/**
* Remove a [Listener] from this instance, preventing it from receiving any further updates.
* Remove an [UpdateListener] such that it does not receive any further updates from this
* instance.
*
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
* the first place.
* @see Listener
* @param listener The [UpdateListener] to remove.
*/
fun removeListener(listener: Listener)
fun removeUpdateListener(listener: UpdateListener)
/** A listener for changes in [MusicRepository] */
interface Listener {
/**
* Add an [IndexingListener] to receive updates from this instance.
*
* @param listener The [UpdateListener] to add.
*/
fun addIndexingListener(listener: IndexingListener)
/**
* Remove an [IndexingListener] such that it does not receive any further updates from this
* instance.
*
* @param listener The [IndexingListener] to remove.
*/
fun removeIndexingListener(listener: IndexingListener)
/**
* Register an [IndexingWorker] to handle loading operations. Will do nothing if one is already
* registered.
*
* @param worker The [IndexingWorker] to register.
*/
fun registerWorker(worker: IndexingWorker)
/**
* Unregister an [IndexingWorker] and drop any work currently being done by it. Does nothing if
* given [IndexingWorker] is not the currently registered instance.
*
* @param worker The [IndexingWorker] to unregister.
*/
fun unregisterWorker(worker: IndexingWorker)
/**
* Generically search for the [Music] associated with the given [Music.UID]. Note that this
* method is much slower that type-specific find implementations, so this should only be used if
* the type of music being searched for is entirely unknown.
*
* @param uid The [Music.UID] to search for.
* @return The expected [Music] information, or null if it could not be found.
*/
fun find(uid: Music.UID): Music?
/**
* Create a new [Playlist] of the given [Song]s.
*
* @param name The name of the new [Playlist].
* @param songs The songs to populate the new [Playlist] with.
*/
suspend fun createPlaylist(name: String, songs: List<Song>)
/**
* Rename a [Playlist].
*
* @param playlist The [Playlist] to rename.
* @param name The name of the new [Playlist].
*/
suspend fun renamePlaylist(playlist: Playlist, name: String)
/**
* Delete a [Playlist].
*
* @param playlist The playlist to delete.
*/
suspend fun deletePlaylist(playlist: Playlist)
/**
* Add the given [Song]s to a [Playlist].
*
* @param songs The [Song]s to add to the [Playlist].
* @param playlist The [Playlist] to add to.
*/
suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist)
/**
* Update the [Song]s of a [Playlist].
*
* @param playlist The [Playlist] to update.
* @param songs The new [Song]s to be contained in the [Playlist].
*/
suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>)
/**
* Request that a music loading operation is started by the current [IndexingWorker]. Does
* nothing if one is not available.
*
* @param withCache Whether to load with the music cache or not.
*/
fun requestIndex(withCache: Boolean)
/**
* Load the music library. Any prior loads will be canceled.
*
* @param worker The [IndexingWorker] to perform the work with.
* @param withCache Whether to load with the music cache or not.
* @return The top-level music loading [Job] started.
*/
fun index(worker: IndexingWorker, withCache: Boolean): Job
/** A listener for changes to the stored music information. */
interface UpdateListener {
/**
* Called when the current [Library] has changed.
* Called when a change to the stored music information occurs.
*
* @param library The new [Library], or null if no [Library] has been loaded yet.
* @param changes The [Changes] that have occurred.
*/
fun onLibraryChanged(library: Library?)
fun onMusicChanges(changes: Changes)
}
/**
* Flags indicating which kinds of music information changed.
*
* @param deviceLibrary Whether the current [DeviceLibrary] has changed.
* @param userLibrary Whether the current [Playlist]s have changed.
*/
data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
/** A listener for events in the music loading process. */
interface IndexingListener {
/** Called when the music loading state changed. */
fun onIndexingStateChanged()
}
/** A persistent worker that can load music in the background. */
interface IndexingWorker {
/** A [Context] required to read device storage */
val context: Context
/** The [CoroutineScope] to perform coroutine music loading work on. */
val scope: CoroutineScope
/**
* Request that the music loading process ([index]) should be started. Any prior loads
* should be canceled.
*
* @param withCache Whether to use the music cache when loading.
*/
fun requestIndex(withCache: Boolean)
}
}
class MusicRepositoryImpl @Inject constructor() : MusicRepository {
private val listeners = mutableListOf<MusicRepository.Listener>()
class MusicRepositoryImpl
@Inject
constructor(
private val cacheRepository: CacheRepository,
private val mediaStoreExtractor: MediaStoreExtractor,
private val tagExtractor: TagExtractor,
private val deviceLibraryFactory: DeviceLibrary.Factory,
private val userLibraryFactory: UserLibrary.Factory
) : MusicRepository {
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
@Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null
@Volatile
override var library: Library? = null
set(value) {
field = value
for (callback in listeners) {
callback.onLibraryChanged(library)
@Volatile override var deviceLibrary: DeviceLibrary? = null
@Volatile override var userLibrary: MutableUserLibrary? = null
@Volatile private var previousCompletedState: IndexingState.Completed? = null
@Volatile private var currentIndexingState: IndexingState? = null
override val indexingState: IndexingState?
get() = currentIndexingState ?: previousCompletedState
@Synchronized
override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
updateListeners.add(listener)
listener.onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = true))
}
@Synchronized
override fun removeUpdateListener(listener: MusicRepository.UpdateListener) {
updateListeners.remove(listener)
}
@Synchronized
override fun addIndexingListener(listener: MusicRepository.IndexingListener) {
indexingListeners.add(listener)
listener.onIndexingStateChanged()
}
@Synchronized
override fun removeIndexingListener(listener: MusicRepository.IndexingListener) {
indexingListeners.remove(listener)
}
@Synchronized
override fun registerWorker(worker: MusicRepository.IndexingWorker) {
if (indexingWorker != null) {
logW("Worker is already registered")
return
}
indexingWorker = worker
if (indexingState == null) {
worker.requestIndex(true)
}
}
@Synchronized
override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
if (indexingWorker !== worker) {
logW("Given worker did not match current worker")
return
}
indexingWorker = null
currentIndexingState = null
}
@Synchronized
override fun find(uid: Music.UID) =
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
?: userLibrary?.findPlaylist(uid))
override suspend fun createPlaylist(name: String, songs: List<Song>) {
val userLibrary = synchronized(this) { userLibrary ?: return }
userLibrary.createPlaylist(name, songs)
notifyUserLibraryChange()
}
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
val userLibrary = synchronized(this) { userLibrary ?: return }
userLibrary.renamePlaylist(playlist, name)
notifyUserLibraryChange()
}
override suspend fun deletePlaylist(playlist: Playlist) {
val userLibrary = synchronized(this) { userLibrary ?: return }
userLibrary.deletePlaylist(playlist)
notifyUserLibraryChange()
}
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
val userLibrary = synchronized(this) { userLibrary ?: return }
userLibrary.addToPlaylist(playlist, songs)
notifyUserLibraryChange()
}
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val userLibrary = synchronized(this) { userLibrary ?: return }
userLibrary.rewritePlaylist(playlist, songs)
notifyUserLibraryChange()
}
@Synchronized
private fun notifyUserLibraryChange() {
for (listener in updateListeners) {
listener.onMusicChanges(
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
}
}
@Synchronized
override fun requestIndex(withCache: Boolean) {
indexingWorker?.requestIndex(withCache)
}
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
worker.scope.launch {
try {
val start = System.currentTimeMillis()
indexImpl(worker, withCache)
logD(
"Music indexing completed successfully in " +
"${System.currentTimeMillis() - start}ms")
} catch (e: CancellationException) {
// Got cancelled, propagate upwards to top-level co-routine.
logD("Loading routine was cancelled")
throw e
} catch (e: Exception) {
// Music loading process failed due to something we have not handled.
logE("Music indexing failed")
logE(e.stackTraceToString())
emitComplete(e)
}
}
@Synchronized
override fun addListener(listener: MusicRepository.Listener) {
listener.onLibraryChanged(library)
listeners.add(listener)
private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) {
if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
logE("Permission check failed")
// No permissions, signal that we can't do anything.
throw NoAudioPermissionException()
}
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
// how long a media database query will take.
emitLoading(IndexingProgress.Indeterminate)
// Do the initial query of the cache and media databases in parallel.
logD("Starting queries")
val mediaStoreQueryJob = worker.scope.tryAsync { mediaStoreExtractor.query() }
val cache =
if (withCache) {
cacheRepository.readCache()
} else {
null
}
val query = mediaStoreQueryJob.await().getOrThrow()
// Now start processing the queried song information in parallel. Songs that can't be
// received from the cache are consisted incomplete and pushed to a separate channel
// that will eventually be processed into completed raw songs.
logD("Starting song discovery")
val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
val mediaStoreJob =
worker.scope.tryAsync {
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
incompleteSongs.close()
}
val metadataJob =
worker.scope.tryAsync {
tagExtractor.consume(incompleteSongs, completeSongs)
completeSongs.close()
}
// Await completed raw songs as they are processed.
val rawSongs = LinkedList<RawSong>()
for (rawSong in completeSongs) {
rawSongs.add(rawSong)
emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
}
// These should be no-ops
mediaStoreJob.await().getOrThrow()
metadataJob.await().getOrThrow()
if (rawSongs.isEmpty()) {
logE("Music library was empty")
throw NoMusicException()
}
// Successfully loaded the library, now save the cache, create the library, and
// read playlist information in parallel.
logD("Discovered ${rawSongs.size} songs, starting finalization")
// TODO: Indicate playlist state in loading process?
emitLoading(IndexingProgress.Indeterminate)
val deviceLibraryChannel = Channel<DeviceLibrary>()
val deviceLibraryJob =
worker.scope.tryAsync(Dispatchers.Main) {
deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) }
}
val userLibraryJob =
worker.scope.tryAsync {
userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() }
}
if (cache == null || cache.invalidated) {
cacheRepository.writeCache(rawSongs)
}
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
val userLibrary = userLibraryJob.await().getOrThrow()
withContext(Dispatchers.Main) {
emitComplete(null)
emitData(deviceLibrary, userLibrary)
}
}
private inline fun <R> CoroutineScope.tryAsync(
context: CoroutineContext = EmptyCoroutineContext,
crossinline block: suspend () -> R
) =
async(context) {
try {
Result.success(block())
} catch (e: Exception) {
Result.failure(e)
}
}
private suspend fun emitLoading(progress: IndexingProgress) {
yield()
synchronized(this) {
currentIndexingState = IndexingState.Indexing(progress)
for (listener in indexingListeners) {
listener.onIndexingStateChanged()
}
}
}
private suspend fun emitComplete(error: Exception?) {
yield()
synchronized(this) {
previousCompletedState = IndexingState.Completed(error)
currentIndexingState = null
for (listener in indexingListeners) {
listener.onIndexingStateChanged()
}
}
}
@Synchronized
override fun removeListener(listener: MusicRepository.Listener) {
listeners.remove(listener)
private fun emitData(deviceLibrary: DeviceLibrary, userLibrary: MutableUserLibrary) {
val deviceLibraryChanged = this.deviceLibrary != deviceLibrary
val userLibraryChanged = this.userLibrary != userLibrary
if (!deviceLibraryChanged && !userLibraryChanged) return
this.deviceLibrary = deviceLibrary
this.userLibrary = userLibrary
val changes = MusicRepository.Changes(deviceLibraryChanged, userLibraryChanged)
for (listener in updateListeners) {
listener.onMusicChanges(changes)
}
}
}

View file

@ -25,8 +25,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.MusicDirectories
import org.oxycblt.auxio.music.fs.Directory
import org.oxycblt.auxio.music.fs.MusicDirectories
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat
@ -55,11 +55,13 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
var artistSort: Sort
/** The [Sort] mode used in [Genre] lists. */
var genreSort: Sort
/** The [Sort] mode used in [Playlist] lists. */
var playlistSort: Sort
/** The [Sort] mode used in an [Album]'s [Song] list. */
var albumSongSort: Sort
/** The [Sort] mode used in an [Artist]'s [Song] list. */
var artistSongSort: Sort
/** The [Sort] mode used in an [Genre]'s [Song] list. */
/** The [Sort] mode used in a [Genre]'s [Song] list. */
var genreSongSort: Sort
interface Listener {
@ -162,6 +164,17 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
}
}
override var playlistSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_playlists_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_playlists_sort), value.intCode)
apply()
}
}
override var albumSongSort: Sort
get() {
var sort =

View file

@ -19,11 +19,15 @@
package org.oxycblt.auxio.music
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.system.Indexer
import kotlinx.coroutines.launch
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
/**
* A [ViewModel] providing data specific to the music loading process.
@ -31,49 +35,171 @@ import org.oxycblt.auxio.music.system.Indexer
* @author Alexander Capehart (OxygenCobalt)
*/
@HiltViewModel
class MusicViewModel @Inject constructor(private val indexer: Indexer) :
ViewModel(), Indexer.Listener {
class MusicViewModel
@Inject
constructor(
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
private val _indexerState = MutableStateFlow<Indexer.State?>(null)
private val _indexingState = MutableStateFlow<IndexingState?>(null)
/** The current music loading state, or null if no loading is going on. */
val indexerState: StateFlow<Indexer.State?> = _indexerState
val indexingState: StateFlow<IndexingState?> = _indexingState
private val _statistics = MutableStateFlow<Statistics?>(null)
/** [Statistics] about the last completed music load. */
val statistics: StateFlow<Statistics?>
get() = _statistics
private val _newPlaylistSongs = MutableEvent<List<Song>>()
/** Flag for opening a dialog to create a playlist of the given [Song]s. */
val newPlaylistSongs: Event<List<Song>> = _newPlaylistSongs
private val _playlistToRename = MutableEvent<Playlist?>()
/** Flag for opening a dialog to rename the given [Playlist]. */
val playlistToRename: Event<Playlist?>
get() = _playlistToRename
private val _playlistToDelete = MutableEvent<Playlist>()
/** Flag for opening a dialog to confirm deletion of the given [Playlist]. */
val playlistToDelete: Event<Playlist>
get() = _playlistToDelete
private val _songsToAdd = MutableEvent<List<Song>>()
/** Flag for opening a dialog to add the given [Song]s to a playlist. */
val songsToAdd: Event<List<Song>> = _songsToAdd
init {
indexer.registerListener(this)
musicRepository.addUpdateListener(this)
musicRepository.addIndexingListener(this)
}
override fun onCleared() {
indexer.unregisterListener(this)
musicRepository.removeUpdateListener(this)
musicRepository.removeIndexingListener(this)
}
override fun onIndexerStateChanged(state: Indexer.State?) {
_indexerState.value = state
if (state is Indexer.State.Complete) {
// New state is a completed library, update the statistics values.
val library = state.result.getOrNull() ?: return
_statistics.value =
Statistics(
library.songs.size,
library.albums.size,
library.artists.size,
library.genres.size,
library.songs.sumOf { it.durationMs })
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return
val deviceLibrary = musicRepository.deviceLibrary ?: return
_statistics.value =
Statistics(
deviceLibrary.songs.size,
deviceLibrary.albums.size,
deviceLibrary.artists.size,
deviceLibrary.genres.size,
deviceLibrary.songs.sumOf { it.durationMs })
}
override fun onIndexingStateChanged() {
_indexingState.value = musicRepository.indexingState
}
/** Requests that the music library should be re-loaded while leveraging the cache. */
fun refresh() {
indexer.requestReindex(true)
musicRepository.requestIndex(true)
}
/** Requests that the music library be re-loaded without the cache. */
fun rescan() {
indexer.requestReindex(false)
musicRepository.requestIndex(false)
}
/**
* Create a new generic [Playlist].
*
* @param name The name of the new [Playlist]. If null, the user will be prompted for one.
* @param songs The [Song]s to be contained in the new playlist.
*/
fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) {
if (name != null) {
viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) }
} else {
_newPlaylistSongs.put(songs)
}
}
/**
* Rename the given playlist.
*
* @param playlist The [Playlist] to rename,
* @param name The new name of the [Playlist]. If null, the user will be prompted for a name.
*/
fun renamePlaylist(playlist: Playlist, name: String? = null) {
if (name != null) {
viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) }
} else {
_playlistToRename.put(playlist)
}
}
/**
* Delete a [Playlist].
*
* @param playlist The playlist to delete.
* @param rude Whether to immediately delete the playlist or prompt the user first. This should
* be false at almost all times.
*/
fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
if (rude) {
viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) }
} else {
_playlistToDelete.put(playlist)
}
}
/**
* Add a [Song] to a [Playlist].
*
* @param song The [Song] to add to the [Playlist].
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
*/
fun addToPlaylist(song: Song, playlist: Playlist? = null) {
addToPlaylist(listOf(song), playlist)
}
/**
* Add an [Album] to a [Playlist].
*
* @param album The [Album] to add to the [Playlist].
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
*/
fun addToPlaylist(album: Album, playlist: Playlist? = null) {
addToPlaylist(musicSettings.albumSongSort.songs(album.songs), playlist)
}
/**
* Add an [Artist] to a [Playlist].
*
* @param artist The [Artist] to add to the [Playlist].
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
*/
fun addToPlaylist(artist: Artist, playlist: Playlist? = null) {
addToPlaylist(musicSettings.artistSongSort.songs(artist.songs), playlist)
}
/**
* Add a [Genre] to a [Playlist].
*
* @param genre The [Genre] to add to the [Playlist].
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
*/
fun addToPlaylist(genre: Genre, playlist: Playlist? = null) {
addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist)
}
/**
* Add [Song]s to a [Playlist].
*
* @param songs The [Song]s to add to the [Playlist].
* @param playlist The [Playlist] to add to. If null, the user will be prompted for one.
*/
fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) {
if (playlist != null) {
viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) }
} else {
_songsToAdd.put(songs)
}
}
/**

View file

@ -27,10 +27,10 @@ import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped
import org.oxycblt.auxio.music.model.RawSong
@Database(entities = [CachedSong::class], version = 27, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() {

View file

@ -19,7 +19,7 @@
package org.oxycblt.auxio.music.cache
import javax.inject.Inject
import org.oxycblt.auxio.music.model.RawSong
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.util.*
/**

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* Library.kt is part of Auxio.
* DeviceLibrary.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,60 +16,44 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.model
package org.oxycblt.auxio.music.device
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import javax.inject.Inject
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.useQuery
import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.music.fs.useQuery
import org.oxycblt.auxio.util.logD
/**
* Organized music library information.
* Organized music library information obtained from device storage.
*
* This class allows for the creation of a well-formed music library graph from raw song
* information. It's generally not expected to create this yourself and instead use
* [MusicRepository].
* information. Instances are immutable. It's generally not expected to create this yourself and
* instead use [MusicRepository].
*
* @author Alexander Capehart
*/
interface Library {
/** All [Song]s in this [Library]. */
interface DeviceLibrary {
/** All [Song]s in this [DeviceLibrary]. */
val songs: List<Song>
/** All [Album]s in this [Library]. */
/** All [Album]s in this [DeviceLibrary]. */
val albums: List<Album>
/** All [Artist]s in this [Library]. */
/** All [Artist]s in this [DeviceLibrary]. */
val artists: List<Artist>
/** All [Genre]s in this [Library]. */
/** All [Genre]s in this [DeviceLibrary]. */
val genres: List<Genre>
/**
* Finds a [Music] item [T] in the library by it's [Music.UID].
* Find a [Song] instance corresponding to the given [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].
* @return The corresponding [Song], or null if one was not found.
*/
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.
*/
fun sanitize(song: Song): Song?
/**
* 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.
*/
fun <T : MusicParent> sanitize(parent: T): T?
fun findSong(uid: Music.UID): Song?
/**
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
@ -80,34 +64,72 @@ interface Library {
*/
fun findSongForUri(context: Context, uri: Uri): Song?
/**
* Find a [Album] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Song], or null if one was not found.
*/
fun findAlbum(uid: Music.UID): Album?
/**
* Find a [Artist] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Song], or null if one was not found.
*/
fun findArtist(uid: Music.UID): Artist?
/**
* Find a [Genre] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Song], or null if one was not found.
*/
fun findGenre(uid: Music.UID): Genre?
/** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */
interface Factory {
/**
* Create a new [DeviceLibrary].
*
* @param rawSongs [RawSong] instances to create a [DeviceLibrary] from.
*/
suspend fun create(rawSongs: List<RawSong>): DeviceLibrary
}
companion object {
/**
* Create an instance of [Library].
* Create an instance of [DeviceLibrary].
*
* @param rawSongs [RawSong]s to create the library out of.
* @param settings [MusicSettings] required.
*/
fun from(rawSongs: List<RawSong>, settings: MusicSettings): Library =
LibraryImpl(rawSongs, settings)
fun from(rawSongs: List<RawSong>, settings: MusicSettings): DeviceLibrary =
DeviceLibraryImpl(rawSongs, settings)
}
}
private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Library {
class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) :
DeviceLibrary.Factory {
override suspend fun create(rawSongs: List<RawSong>): DeviceLibrary =
DeviceLibraryImpl(rawSongs, musicSettings)
}
private class DeviceLibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : DeviceLibrary {
override val songs = buildSongs(rawSongs, settings)
override val albums = buildAlbums(songs, settings)
override val artists = buildArtists(songs, albums, settings)
override val genres = buildGenres(songs, settings)
// Use a mapping to make finding information based on it's UID much faster.
private val uidMap = buildMap {
songs.forEach { put(it.uid, it.finalize()) }
albums.forEach { put(it.uid, it.finalize()) }
artists.forEach { put(it.uid, it.finalize()) }
genres.forEach { put(it.uid, it.finalize()) }
}
private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } }
private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } }
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
override fun equals(other: Any?) =
other is Library &&
other is DeviceLibrary &&
other.songs == songs &&
other.albums == albums &&
other.artists == artists &&
@ -121,18 +143,10 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
return hashCode
}
/**
* 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].
*/
@Suppress("UNCHECKED_CAST") override fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
override fun sanitize(song: Song) = find<Song>(song.uid)
override fun <T : MusicParent> sanitize(parent: T) = find<T>(parent.uid)
override fun findSong(uid: Music.UID) = songUidMap[uid]
override fun findAlbum(uid: Music.UID) = albumUidMap[uid]
override fun findArtist(uid: Music.UID) = artistUidMap[uid]
override fun findGenre(uid: Music.UID) = genreUidMap[uid]
override fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(
@ -156,7 +170,7 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
*/
private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings) =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.songs(rawSongs.map { SongImpl(it, settings) }.distinct())
.songs(rawSongs.map { SongImpl(it, settings) }.distinctBy { it.uid })
/**
* Build a list of [Album]s from the given [Song]s.

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* Separators.kt is part of Auxio.
* DeviceModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,17 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.metadata
package org.oxycblt.auxio.music.device
/**
* Defines the allowed separator characters that can be used to delimit multi-value tags.
*
* @author Alexander Capehart (OxygenCobalt)
*/
object Separators {
const val COMMA = ','
const val SEMICOLON = ';'
const val SLASH = '/'
const val PLUS = '+'
const val AND = '&'
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface DeviceModule {
@Binds fun deviceLibraryProvider(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* MusicImpl.kt is part of Auxio.
* DeviceMusicImpl.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,26 +16,23 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.model
package org.oxycblt.auxio.music.device
import android.content.Context
import androidx.annotation.VisibleForTesting
import java.security.MessageDigest
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort
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
import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.fs.toCoverUri
import org.oxycblt.auxio.music.info.*
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.parseId3GenreNames
import org.oxycblt.auxio.music.metadata.parseMultiValue
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.storage.Path
import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.music.storage.toCoverUri
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.toUuidOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.auxio.util.update
/**
* Library-backed implementation of [Song].
@ -44,7 +41,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @param musicSettings [MusicSettings] to for user parsing configuration.
* @author Alexander Capehart (OxygenCobalt)
*/
class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
class SongImpl(private val 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) }
@ -62,10 +59,11 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
update(rawSong.artistNames)
update(rawSong.albumArtistNames)
}
override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" }
override val rawSortName = rawSong.sortName
override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName
override val name =
Name.Known.from(
requireNotNull(rawSong.name) { "Invalid raw: No title" },
rawSong.sortName,
musicSettings)
override val track = rawSong.track
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
@ -87,9 +85,9 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
override val album: Album
get() = unlikelyToBeNull(_album)
// Note: Only compare by UID so songs that differ only in MBID are treated differently.
override fun hashCode() = uid.hashCode()
override fun equals(other: Any?) = other is Song && uid == other.uid
override fun hashCode() = 31 * uid.hashCode() + rawSong.hashCode()
override fun equals(other: Any?) =
other is SongImpl && uid == other.uid && rawSong == other.rawSong
private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
@ -238,10 +236,7 @@ class AlbumImpl(
update(rawAlbum.name)
update(rawAlbum.rawArtists.map { it.name })
}
override val rawName = rawAlbum.name
override val rawSortName = rawAlbum.sortName
override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName
override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings)
override val dates = Date.Range.from(songs.mapNotNull { it.date })
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
@ -249,11 +244,15 @@ class AlbumImpl(
override val durationMs: Long
override val dateAdded: Long
// Note: Append song contents to MusicParent equality so that Groups with
// the same UID but different contents are not equal.
override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
override fun hashCode(): Int {
var hashCode = uid.hashCode()
hashCode = 31 * hashCode + rawAlbum.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
return hashCode
}
override fun equals(other: Any?) =
other is AlbumImpl && uid == other.uid && songs == other.songs
other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs
private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist>
@ -331,21 +330,29 @@ class ArtistImpl(
// 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 sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) }
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
override val songs: List<Song>
override val name =
rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) }
?: Name.Unknown(R.string.def_artist)
override val songs: List<Song>
override val albums: List<Album>
override val durationMs: Long?
override val isCollaborator: Boolean
// Note: Append song contents to MusicParent equality so that Groups with
// the same UID but different contents are not equal.
override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
// Note: Append song contents to MusicParent equality so that artists with
// the same UID but different songs are not equal.
override fun hashCode(): Int {
var hashCode = uid.hashCode()
hashCode = 31 * hashCode + rawArtist.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
return hashCode
}
override fun equals(other: Any?) =
other is ArtistImpl && uid == other.uid && songs == other.songs
other is ArtistImpl &&
uid == other.uid &&
rawArtist == other.rawArtist &&
songs == other.songs
override lateinit var genres: List<Genre>
@ -416,20 +423,23 @@ class GenreImpl(
override val songs: List<SongImpl>
) : Genre {
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
override val rawName = rawGenre.name
override val rawSortName = rawName
override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) }
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
override val name =
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
?: Name.Unknown(R.string.def_genre)
override val albums: List<Album>
override val artists: List<Artist>
override val durationMs: Long
// Note: Append song contents to MusicParent equality so that Groups with
// the same UID but different contents are not equal.
override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
override fun hashCode(): Int {
var hashCode = uid.hashCode()
hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
return hashCode
}
override fun equals(other: Any?) =
other is GenreImpl && uid == other.uid && songs == other.songs
other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs
init {
val distinctAlbums = mutableSetOf<Album>()
@ -467,60 +477,8 @@ class GenreImpl(
*
* @return This instance upcasted to [Genre].
*/
fun finalize(): Music {
fun finalize(): Genre {
check(songs.isNotEmpty()) { "Malformed genre: Empty" }
return this
}
}
/**
* Update a [MessageDigest] with a lowercase [String].
*
* @param string The [String] to hash. If null, it will not be hashed.
*/
@VisibleForTesting
fun MessageDigest.update(string: String?) {
if (string != null) {
update(string.lowercase().toByteArray())
} else {
update(0)
}
}
/**
* Update a [MessageDigest] with the string representation of a [Date].
*
* @param date The [Date] to hash. If null, nothing will be done.
*/
@VisibleForTesting
fun MessageDigest.update(date: Date?) {
if (date != null) {
update(date.toString().toByteArray())
} else {
update(0)
}
}
/**
* 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
fun MessageDigest.update(strings: List<String?>) {
strings.forEach(::update)
}
/**
* Update a [MessageDigest] with the little-endian bytes of a [Int].
*
* @param n The [Int] to write. If null, nothing will be done.
*/
@VisibleForTesting
fun MessageDigest.update(n: Int?) {
if (n != null) {
update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte()))
} else {
update(0)
}
}

View file

@ -16,19 +16,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.model
package org.oxycblt.auxio.music.device
import java.util.UUID
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.*
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.fs.Directory
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.ReleaseType
/**
* Raw information about a [SongImpl] obtained from the filesystem/Extractor instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class RawSong(
data class RawSong(
/**
* The ID of the [SongImpl]'s audio file, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the audio file.
@ -50,15 +51,15 @@ class RawSong(
var extensionMimeType: String? = null,
/** @see Music.UID */
var musicBrainzId: String? = null,
/** @see Music.rawName */
/** @see Music.name */
var name: String? = null,
/** @see Music.rawSortName */
/** @see Music.name */
var sortName: String? = null,
/** @see Song.track */
var track: Int? = null,
/** @see Disc.number */
/** @see Song.disc */
var disc: Int? = null,
/** @See Disc.name */
/** @See Song.disc */
var subtitle: String? = null,
/** @see Song.date */
var date: Date? = null,
@ -93,7 +94,7 @@ class RawSong(
*
* @author Alexander Capehart (OxygenCobalt)
*/
class RawAlbum(
data class RawAlbum(
/**
* The ID of the [AlbumImpl]'s grouping, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the system-provided cover art.
@ -101,9 +102,9 @@ class RawAlbum(
val mediaStoreId: Long,
/** @see Music.uid */
val musicBrainzId: UUID?,
/** @see Music.rawName */
/** @see Music.name */
val name: String,
/** @see Music.rawSortName */
/** @see Music.name */
val sortName: String?,
/** @see Album.releaseType */
val releaseType: ReleaseType?,
@ -140,12 +141,12 @@ class RawAlbum(
*
* @author Alexander Capehart (OxygenCobalt)
*/
class RawArtist(
data class RawArtist(
/** @see Music.UID */
val musicBrainzId: UUID? = null,
/** @see Music.rawName */
/** @see Music.name */
val name: String? = null,
/** @see Music.rawSortName */
/** @see Music.name */
val sortName: String? = null
) {
// Artists are grouped as follows:
@ -182,17 +183,17 @@ class RawArtist(
*
* @author Alexander Capehart (OxygenCobalt)
*/
class RawGenre(
/** @see Music.rawName */
data class RawGenre(
/** @see Music.name */
val name: String? = null
) {
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
// Cache the hashCode for HashMap efficiency.
private val hashCode = name?.lowercase().hashCode()
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
override fun hashCode() = hashCode
override fun equals(other: Any?) =

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.fs
import android.view.View
import android.view.ViewGroup

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
* Filesystem.kt is part of Auxio.
* Fs.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.fs
import android.content.Context
import android.media.MediaFormat

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* StorageModule.kt is part of Auxio.
* FsModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.fs
import android.content.Context
import dagger.Module
@ -28,7 +28,7 @@ import org.oxycblt.auxio.music.MusicSettings
@Module
@InstallIn(SingletonComponent::class)
class StorageModule {
class FsModule {
@Provides
fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) =
MediaStoreExtractor.from(context, musicSettings)

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.fs
import android.content.Context
import android.database.Cursor
@ -31,10 +31,10 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.cache.Cache
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.parseId3v2PositionField
import org.oxycblt.auxio.music.metadata.transformPositionField
import org.oxycblt.auxio.music.model.RawSong
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
@ -178,8 +178,8 @@ private abstract class BaseMediaStoreExtractor(
while (cursor.moveToNext()) {
// Assume that a song can't inhabit multiple genre entries, as I
// doubt
// MediaStore is actually aware that songs can have multiple genres.
// doubt MediaStore is actually aware that songs can have multiple
// genres.
genreNamesMap[cursor.getLong(songIdIndex)] = name
}
}
@ -210,7 +210,6 @@ private abstract class BaseMediaStoreExtractor(
// Free the cursor and signal that no more incomplete songs will be produced by
// this extractor.
query.close()
incompleteSongs.close()
}
/**
@ -311,9 +310,8 @@ private abstract class BaseMediaStoreExtractor(
rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from)
// A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it
// the
// file is not actually in the root internal storage directory. We can't do anything to
// fix this, really.
// the file is not actually in the root internal storage directory. We can't do
// anything to fix this, really.
rawSong.albumName = cursor.getString(albumIndex)
// Android does not make a non-existent artist tag null, it instead fills it in
// as <unknown>, which makes absolutely no sense given how other columns default
@ -356,9 +354,6 @@ private abstract class BaseMediaStoreExtractor(
// Note: The separation between version-specific backends may not be the cleanest. To preserve
// speed, we only want to add redundancy on known issues, not with possible issues.
// Note: The separation between version-specific backends may not be the cleanest. To preserve
// speed, we only want to add redundancy on known issues, not with possible issues.
private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
BaseMediaStoreExtractor(context, musicSettings) {
override val projection: Array<String>

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.fs
import android.content.ActivityNotFoundException
import android.net.Uri

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.fs
import android.annotation.SuppressLint
import android.content.ContentResolver

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.metadata
package org.oxycblt.auxio.music.info
import android.content.Context
import java.text.ParseException
@ -185,9 +185,10 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
* A [Regex] that can parse a variable-precision ISO-8601 timestamp. Derived from
* https://github.com/quodlibet/mutagen
*/
private val ISO8601_REGEX =
private val ISO8601_REGEX by lazy {
Regex(
"""^(\d{4})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""")
}
/**
* Create a [Date] from a year component.

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.metadata
package org.oxycblt.auxio.music.info
import org.oxycblt.auxio.list.Item
@ -27,7 +27,7 @@ import org.oxycblt.auxio.list.Item
* @param name The name of the disc group, if any. Null if not present.
*/
class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
override fun hashCode() = number.hashCode()
override fun equals(other: Any?) = other is Disc && number == other.number
override fun hashCode() = number.hashCode()
override fun compareTo(other: Disc) = number.compareTo(other.number)
}

View file

@ -0,0 +1,219 @@
/*
* Copyright (c) 2023 Auxio Project
* Name.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.info
import android.content.Context
import androidx.annotation.StringRes
import java.text.CollationKey
import java.text.Collator
import org.oxycblt.auxio.music.MusicSettings
/**
* The name of a music item.
*
* This class automatically implements
*
* @author Alexander Capehart
*/
sealed interface Name : Comparable<Name> {
/**
* A logical first character that can be used to collate a sorted list of music.
*
* TODO: Move this to the home package
*/
val thumb: String
/**
* Get a human-readable string representation of this instance.
*
* @param context [Context] required.
*/
fun resolve(context: Context): String
/** A name that could be obtained for the music item. */
sealed class Known : Name {
/** The raw name string obtained. Should be ignored in favor of [resolve]. */
abstract val raw: String
/** The raw sort name string obtained. */
abstract val sort: String?
/** A tokenized version of the name that will be compared. */
protected abstract val sortTokens: List<SortToken>
/** An individual part of a name string that can be compared intelligently. */
protected data class SortToken(val collationKey: CollationKey, val type: Type) :
Comparable<SortToken> {
override fun compareTo(other: SortToken): Int {
// Numeric tokens should always be lower than lexicographic tokens.
val modeComp = type.compareTo(other.type)
if (modeComp != 0) {
return modeComp
}
// Numeric strings must be ordered by magnitude, thus immediately short-circuit
// the comparison if the lengths do not match.
if (type == Type.NUMERIC &&
collationKey.sourceString.length != other.collationKey.sourceString.length) {
return collationKey.sourceString.length - other.collationKey.sourceString.length
}
return collationKey.compareTo(other.collationKey)
}
/** Denotes the type of comparison to be performed with this token. */
enum class Type {
/** Compare as a digit string, like "65". */
NUMERIC,
/** Compare as a standard alphanumeric string, like "65daysofstatic" */
LEXICOGRAPHIC
}
}
final override val thumb: String
get() =
// TODO: Remove these safety checks once you have real unit testing
sortTokens
.firstOrNull()
?.run { collationKey.sourceString.firstOrNull() }
?.let { if (it.isDigit()) "#" else it.uppercase() }
?: "?"
final override fun resolve(context: Context) = raw
final override fun compareTo(other: Name) =
when (other) {
is Known -> {
// Progressively compare the sort tokens between each known name.
sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) ->
acc.takeIf { it != 0 } ?: token.compareTo(otherToken)
}
}
// Unknown names always come before known names.
is Unknown -> 1
}
companion object {
/**
* Create a new instance of [Name.Known]
*
* @param raw The raw name obtained from the music item
* @param sort The raw sort name obtained from the music item
* @param musicSettings [MusicSettings] required for name configuration.
*/
fun from(raw: String, sort: String?, musicSettings: MusicSettings): Known =
if (musicSettings.intelligentSorting) {
IntelligentKnownName(raw, sort)
} else {
SimpleKnownName(raw, sort)
}
}
}
/**
* A placeholder name that is used when a [Known] name could not be obtained for the item.
*
* @author Alexander Capehart
*/
data class Unknown(@StringRes val stringRes: Int) : Name {
override val thumb = "?"
override fun resolve(context: Context) = context.getString(stringRes)
override fun compareTo(other: Name) =
when (other) {
// Unknown names do not need any direct comparison right now.
is Unknown -> 0
// Unknown names always come before known names.
is Known -> -1
}
}
}
private val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
private val PUNCT_REGEX by lazy { Regex("[\\p{Punct}+]") }
/**
* Plain [Name.Known] implementation that is internationalization-safe.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private data class SimpleKnownName(override val raw: String, override val sort: String?) :
Name.Known() {
override val sortTokens = listOf(parseToken(sort ?: raw))
private fun parseToken(name: String): SortToken {
// Remove excess punctuation from the string, as those usually aren't considered in sorting.
val stripped = name.replace(PUNCT_REGEX, "").ifEmpty { name }
val collationKey = COLLATOR.getCollationKey(stripped)
// Always use lexicographic mode since we aren't parsing any numeric components
return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC)
}
}
/**
* [Name.Known] implementation that adds advanced sorting behavior at the cost of localization.
*
* @author Alexander Capehart (OxygenCobalt)
*/
private data class IntelligentKnownName(override val raw: String, override val sort: String?) :
Name.Known() {
override val sortTokens = parseTokens(sort ?: raw)
private fun parseTokens(name: String): List<SortToken> {
val stripped =
name
// Remove excess punctuation from the string, as those u
.replace(PUNCT_REGEX, "")
.ifEmpty { name }
.run {
// Strip any english articles like "the" or "an" from the start, as music
// sorting should ignore such when possible.
when {
length > 4 && startsWith("the ", ignoreCase = true) -> substring(4)
length > 3 && startsWith("an ", ignoreCase = true) -> substring(3)
length > 2 && startsWith("a ", ignoreCase = true) -> substring(2)
else -> this
}
}
// To properly compare numeric components in names, we have to split them up into
// individual lexicographic and numeric tokens and then individually compare them
// with special logic.
return TOKEN_REGEX.findAll(stripped).mapTo(mutableListOf()) { match ->
// Remove excess whitespace where possible
val token = match.value.trim().ifEmpty { match.value }
val collationKey: CollationKey
val type: SortToken.Type
// Separate each token into their numeric and lexicographic counterparts.
if (token.first().isDigit()) {
// The digit string comparison breaks with preceding zero digits, remove those
val digits = token.trimStart('0').ifEmpty { token }
// Other languages have other types of digit strings, still use collation keys
collationKey = COLLATOR.getCollationKey(digits)
type = SortToken.Type.NUMERIC
} else {
collationKey = COLLATOR.getCollationKey(token)
type = SortToken.Type.LEXICOGRAPHIC
}
SortToken(collationKey, type)
}
}
companion object {
private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") }
}
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.metadata
package org.oxycblt.auxio.music.info
import org.oxycblt.auxio.R
@ -28,15 +28,15 @@ import org.oxycblt.auxio.R
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class ReleaseType {
sealed interface ReleaseType {
/**
* A specification of what kind of performance this release is. If null, the release is
* considered "Plain".
*/
abstract val refinement: Refinement?
val refinement: Refinement?
/** The string resource corresponding to the name of this release type to show in the UI. */
abstract val stringRes: Int
val stringRes: Int
/**
* A plain album.
@ -44,7 +44,7 @@ sealed class ReleaseType {
* @param refinement A specification of what kind of performance this release is. If null, the
* release is considered "Plain".
*/
data class Album(override val refinement: Refinement?) : ReleaseType() {
data class Album(override val refinement: Refinement?) : ReleaseType {
override val stringRes: Int
get() =
when (refinement) {
@ -61,7 +61,7 @@ sealed class ReleaseType {
* @param refinement A specification of what kind of performance this release is. If null, the
* release is considered "Plain".
*/
data class EP(override val refinement: Refinement?) : ReleaseType() {
data class EP(override val refinement: Refinement?) : ReleaseType {
override val stringRes: Int
get() =
when (refinement) {
@ -78,7 +78,7 @@ sealed class ReleaseType {
* @param refinement A specification of what kind of performance this release is. If null, the
* release is considered "Plain".
*/
data class Single(override val refinement: Refinement?) : ReleaseType() {
data class Single(override val refinement: Refinement?) : ReleaseType {
override val stringRes: Int
get() =
when (refinement) {
@ -95,7 +95,7 @@ sealed class ReleaseType {
* @param refinement A specification of what kind of performance this release is. If null, the
* release is considered "Plain".
*/
data class Compilation(override val refinement: Refinement?) : ReleaseType() {
data class Compilation(override val refinement: Refinement?) : ReleaseType {
override val stringRes: Int
get() =
when (refinement) {
@ -110,7 +110,7 @@ sealed class ReleaseType {
* A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually
* visual) media.
*/
object Soundtrack : ReleaseType() {
object Soundtrack : ReleaseType {
override val refinement: Refinement?
get() = null
@ -122,7 +122,7 @@ sealed class ReleaseType {
* A (DJ) Mix. These are usually one large track consisting of the artist playing several
* sub-tracks with smooth transitions between them.
*/
object Mix : ReleaseType() {
object Mix : ReleaseType {
override val refinement: Refinement?
get() = null
@ -134,7 +134,7 @@ sealed class ReleaseType {
* A Mix-tape. These are usually [EP]-sized releases of music made to promote an Artist or a
* future release.
*/
object Mixtape : ReleaseType() {
object Mixtape : ReleaseType {
override val refinement: Refinement?
get() = null

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* AudioInfo.kt is part of Auxio.
* AudioProperties.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,7 +24,7 @@ import android.media.MediaFormat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
@ -37,32 +37,33 @@ import org.oxycblt.auxio.util.logW
* @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
* @author Alexander Capehart (OxygenCobalt)
*/
data class AudioInfo(
data class AudioProperties(
val bitrateKbps: Int?,
val sampleRateHz: Int?,
val resolvedMimeType: MimeType
) {
/** Implements the process of extracting [AudioInfo] from a given [Song]. */
interface Provider {
/** Implements the process of extracting [AudioProperties] from a given [Song]. */
interface Factory {
/**
* Extract the [AudioInfo] of a given [Song].
* Extract the [AudioProperties] of a given [Song].
*
* @param song The [Song] to read.
* @return The [AudioInfo] of the [Song], if possible to obtain.
* @return The [AudioProperties] of the [Song], if possible to obtain.
*/
suspend fun extract(song: Song): AudioInfo
suspend fun extract(song: Song): AudioProperties
}
}
/**
* A framework-backed implementation of [AudioInfo.Provider].
* A framework-backed implementation of [AudioProperties.Factory].
*
* @param context [Context] required to read audio files.
*/
class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) :
AudioInfo.Provider {
class AudioPropertiesFactoryImpl
@Inject
constructor(@ApplicationContext private val context: Context) : AudioProperties.Factory {
override suspend fun extract(song: Song): AudioInfo {
override suspend fun extract(song: Song): AudioProperties {
// While we would use ExoPlayer to extract this information, it doesn't support
// common data like bit rate in progressive data sources due to there being no
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
@ -76,7 +77,7 @@ class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val
// that we can show.
logW("Unable to extract song attributes.")
logW(e.stackTraceToString())
return AudioInfo(null, null, song.mimeType)
return AudioProperties(null, null, song.mimeType)
}
// Get the first track from the extractor (This is basically always the only
@ -122,6 +123,6 @@ class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val
extractor.release()
return AudioInfo(bitrate, sampleRate, resolvedMimeType)
return AudioProperties(bitrate, sampleRate, resolvedMimeType)
}
}

View file

@ -26,7 +26,7 @@ import dagger.hilt.components.SingletonComponent
@Module
@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
@Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor
@Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory
@Binds fun audioPropertiesFactory(factory: AudioPropertiesFactoryImpl): AudioProperties.Factory
}

View file

@ -99,6 +99,14 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
return separators
}
private object Separators {
const val COMMA = ','
const val SEMICOLON = ';'
const val SLASH = '/'
const val PLUS = '+'
const val AND = '&'
}
private companion object {
const val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS"
}

View file

@ -18,11 +18,11 @@
package org.oxycblt.auxio.music.metadata
import com.google.android.exoplayer2.MetadataRetriever
import androidx.media3.exoplayer.MetadataRetriever
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.model.RawSong
import org.oxycblt.auxio.music.device.RawSong
/**
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
@ -87,8 +87,6 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork
}
}
} while (ongoingTasks)
completeSongs.close()
}
private companion object {

View file

@ -209,7 +209,7 @@ private fun String.parseId3v1Genre(): String? {
* A [Regex] that implements parsing for ID3v2's genre format. Derived from mutagen:
* https://github.com/quodlibet/mutagen
*/
private val ID3V2_GENRE_RE = Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?")
private val ID3V2_GENRE_RE by lazy { Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?") }
/**
* Parse an ID3v2 integer genre field, which has support for multiple genre values and combined
@ -228,7 +228,7 @@ private fun String.parseId3v2Genre(): List<String>? {
// Case 1: Genre IDs in the format (INT|RX|CR). If these exist, parse them as
// ID3v1 tags.
val genreIds = groups.getOrNull(1)
if (genreIds != null && genreIds.isNotEmpty()) {
if (!genreIds.isNullOrEmpty()) {
val ids = genreIds.substring(1, genreIds.lastIndex).split(")(")
for (id in ids) {
id.parseId3v1Genre()?.let(genres::add)
@ -238,7 +238,7 @@ private fun String.parseId3v2Genre(): List<String>? {
// Case 2: Genre names as a normal string. The only case we have to look out for are
// escaped strings formatted as ((genre).
val genreName = groups.getOrNull(3)
if (genreName != null && genreName.isNotEmpty()) {
if (!genreName.isNullOrEmpty()) {
if (genreName.startsWith("((")) {
genres.add(genreName.substring(1))
} else {

View file

@ -19,14 +19,15 @@
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 androidx.media3.common.MediaItem
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.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.music.device.RawSong
import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -56,14 +57,26 @@ interface 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.
*/
class TagWorkerFactoryImpl
@Inject
constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory {
override fun create(rawSong: RawSong): TagWorker =
// 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 class TagWorkerImpl(
private val rawSong: RawSong,
private val future: Future<TrackGroupArray>
) : TagWorker {
override fun poll(): RawSong? {
if (!future.isDone) {
// Not done yet, nothing to do.
@ -95,12 +108,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
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() }
@ -123,6 +130,9 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
// 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
// TODO: Show original and normal dates side-by-side
// TODO: Handle dates that are in "January" because the actual specific release date
// isn't known?
(textFrames["TDOR"]?.run { Date.from(first()) }
?: textFrames["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { Date.from(first()) }
@ -162,23 +172,15 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
(textFrames["TCMP"]
?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"])
?.let {
// Ignore invalid instances of this tag
if (it.size != 1 || it[0] != "1") return@let
// Change the metadata to be a compilation album made by "Various Artists"
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.
@ -212,11 +214,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
}
}
/**
* 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() }
@ -270,28 +267,15 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
// Compilation Flag
(comments["compilation"] ?: comments["itunescompilation"])?.let {
// Ignore invalid instances of this tag
if (it.size != 1 || it[0] != "1") return@let
// Change the metadata to be a compilation album made by "Various Artists"
rawSong.albumArtistNames =
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
}
}
class Factory @Inject constructor(private val mediaSourceFactory: MediaSource.Factory) :
TagWorker.Factory {
override fun create(rawSong: RawSong) =
// Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely.
TagWorkerImpl(
rawSong,
MetadataRetriever.retrieveMetadata(
mediaSourceFactory,
MediaItem.fromUri(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }
.toAudioUri())))
}
private companion object {
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
val COMPILATION_RELEASE_TYPES = listOf("compilation")

View file

@ -18,10 +18,10 @@
package org.oxycblt.auxio.music.metadata
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.InternalFrame
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import androidx.media3.common.Metadata
import androidx.media3.extractor.metadata.id3.InternalFrame
import androidx.media3.extractor.metadata.id3.TextInformationFrame
import androidx.media3.extractor.metadata.vorbis.VorbisComment
/**
* Processing wrapper for [Metadata] that allows organized access to text-based audio tags.

View file

@ -0,0 +1,104 @@
/*
* Copyright (c) 2023 Auxio Project
* AddToPlaylistDialog.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.picker
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.showToast
/**
* A dialog that allows the user to pick a specific playlist to add song(s) to.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class AddToPlaylistDialog :
ViewBindingDialogFragment<DialogMusicChoicesBinding>(),
ClickableListListener<PlaylistChoice>,
NewPlaylistFooterAdapter.Listener {
private val musicModel: MusicViewModel by activityViewModels()
private val pickerModel: PlaylistPickerViewModel by viewModels()
// Information about what playlist to name for is initially within the navigation arguments
// as UIDs, as that is the only safe way to parcel playlist information.
private val args: AddToPlaylistDialogArgs by navArgs()
private val choiceAdapter = PlaylistChoiceAdapter(this)
private val footerAdapter = NewPlaylistFooterAdapter(this)
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.lbl_playlists).setNegativeButton(R.string.lbl_cancel, null)
}
override fun onCreateBinding(inflater: LayoutInflater) =
DialogMusicChoicesBinding.inflate(inflater)
override fun onBindingCreated(binding: DialogMusicChoicesBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
binding.choiceRecycler.apply {
itemAnimator = null
adapter = ConcatAdapter(choiceAdapter, footerAdapter)
}
// --- VIEWMODEL SETUP ---
pickerModel.setSongsToAdd(args.songUids)
collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs)
collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices)
}
override fun onDestroyBinding(binding: DialogMusicChoicesBinding) {
super.onDestroyBinding(binding)
binding.choiceRecycler.adapter = null
}
override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) {
musicModel.addToPlaylist(pickerModel.currentSongsToAdd.value ?: return, item.playlist)
requireContext().showToast(R.string.lng_playlist_added)
findNavController().navigateUp()
}
override fun onNewPlaylist() {
musicModel.createPlaylist(songs = pickerModel.currentSongsToAdd.value ?: return)
}
private fun updatePendingSongs(songs: List<Song>?) {
if (songs == null) {
// No songs to feasibly add to a playlist, leave.
findNavController().navigateUp()
}
}
private fun updatePlaylistChoices(choices: List<PlaylistChoice>) {
choiceAdapter.update(choices, null)
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2023 Auxio Project
* DeletePlaylistDialog.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.picker
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A [ViewBindingDialogFragment] that asks the user to confirm the deletion of a [Playlist].
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class DeletePlaylistDialog : ViewBindingDialogFragment<DialogDeletePlaylistBinding>() {
private val pickerModel: PlaylistPickerViewModel by viewModels()
private val musicModel: MusicViewModel by activityViewModels()
// Information about what playlist to name for is initially within the navigation arguments
// as UIDs, as that is the only safe way to parcel playlist information.
private val args: DeletePlaylistDialogArgs by navArgs()
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
.setTitle(R.string.lbl_confirm_delete_playlist)
.setPositiveButton(R.string.lbl_delete) { _, _ ->
// Now we can delete the playlist for-real this time.
musicModel.deletePlaylist(
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)
requireContext().showToast(R.string.lng_playlist_deleted)
}
.setNegativeButton(R.string.lbl_cancel, null)
}
override fun onCreateBinding(inflater: LayoutInflater) =
DialogDeletePlaylistBinding.inflate(inflater)
override fun onBindingCreated(
binding: DialogDeletePlaylistBinding,
savedInstanceState: Bundle?
) {
super.onBindingCreated(binding, savedInstanceState)
// --- VIEWMODEL SETUP ---
pickerModel.setPlaylistToDelete(args.playlistUid)
collectImmediately(pickerModel.currentPlaylistToDelete, ::updatePlaylistToDelete)
}
private fun updatePlaylistToDelete(playlist: Playlist?) {
if (playlist == null) {
// Playlist does not exist anymore, leave
findNavController().navigateUp()
return
}
requireBinding().deletionInfo.text =
getString(R.string.fmt_deletion_info, playlist.name.resolve(requireContext()))
}
}

View file

@ -0,0 +1,103 @@
/*
* Copyright (c) 2023 Auxio Project
* NewPlaylistDialog.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.picker
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A dialog allowing the name of a new playlist to be chosen before committing it to the database.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>() {
private val musicModel: MusicViewModel by activityViewModels()
private val pickerModel: PlaylistPickerViewModel by viewModels()
// Information about what playlist to name for is initially within the navigation arguments
// as UIDs, as that is the only safe way to parcel playlist information.
private val args: NewPlaylistDialogArgs by navArgs()
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
.setTitle(R.string.lbl_new_playlist)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
val pendingPlaylist = unlikelyToBeNull(pickerModel.currentPendingPlaylist.value)
val name =
when (val chosenName = pickerModel.chosenName.value) {
is ChosenName.Valid -> chosenName.value
is ChosenName.Empty -> pendingPlaylist.preferredName
else -> throw IllegalStateException()
}
// TODO: Navigate to playlist if there are songs in it
musicModel.createPlaylist(name, pendingPlaylist.songs)
requireContext().showToast(R.string.lng_playlist_created)
findNavController().apply {
navigateUp()
// Do an additional navigation away from the playlist addition dialog, if
// needed. If that dialog isn't present, this should be a no-op. Hopefully.
navigateUp()
}
}
.setNegativeButton(R.string.lbl_cancel, null)
}
override fun onCreateBinding(inflater: LayoutInflater) =
DialogPlaylistNameBinding.inflate(inflater)
override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) }
// --- VIEWMODEL SETUP ---
pickerModel.setPendingPlaylist(requireContext(), args.songUids)
collectImmediately(pickerModel.currentPendingPlaylist, ::updatePendingPlaylist)
collectImmediately(pickerModel.chosenName, ::updateChosenName)
}
private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) {
if (pendingPlaylist == null) {
findNavController().navigateUp()
return
}
requireBinding().playlistName.hint = pendingPlaylist.preferredName
}
private fun updateChosenName(chosenName: ChosenName) {
(dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled =
chosenName is ChosenName.Valid || chosenName is ChosenName.Empty
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2023 Auxio Project
* NewPlaylistFooterAdapter.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.picker
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemNewPlaylistChoiceBinding
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.util.inflater
/**
* A purely-visual [RecyclerView.Adapter] that acts as a footer providing a "New Playlist" choice in
* [AddToPlaylistDialog].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NewPlaylistFooterAdapter(private val listener: Listener) :
RecyclerView.Adapter<NewPlaylistFooterViewHolder>() {
override fun getItemCount() = 1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
NewPlaylistFooterViewHolder.from(parent)
override fun onBindViewHolder(holder: NewPlaylistFooterViewHolder, position: Int) {
holder.bind(listener)
}
/** A listener for [NewPlaylistFooterAdapter] interactions. */
interface Listener {
/**
* Called when the footer has been pressed, requesting to create a new playlist to add to.
*/
fun onNewPlaylist()
}
}
/**
* A [RecyclerView.ViewHolder] that displays a "New Playlist" choice in [NewPlaylistFooterAdapter].
* Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NewPlaylistFooterViewHolder
private constructor(private val binding: ItemNewPlaylistChoiceBinding) :
DialogRecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param listener A [NewPlaylistFooterAdapter.Listener] to bind interactions to.
*/
fun bind(listener: NewPlaylistFooterAdapter.Listener) {
binding.root.setOnClickListener { listener.onNewPlaylist() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
NewPlaylistFooterViewHolder(
ItemNewPlaylistChoiceBinding.inflate(parent.context.inflater))
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistChoiceAdapter.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.picker
import android.view.View
import android.view.ViewGroup
import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
/**
* A [FlexibleListAdapter] that displays a list of [PlaylistChoice] options to select from in
* [AddToPlaylistDialog].
*
* @param listener [ClickableListListener] to bind interactions to.
*/
class PlaylistChoiceAdapter(val listener: ClickableListListener<PlaylistChoice>) :
FlexibleListAdapter<PlaylistChoice, PlaylistChoiceViewHolder>(
PlaylistChoiceViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
PlaylistChoiceViewHolder.from(parent)
override fun onBindViewHolder(holder: PlaylistChoiceViewHolder, position: Int) {
holder.bind(getItem(position), listener)
}
}
/**
* A [DialogRecyclerView.ViewHolder] that displays an individual playlist choice. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistChoiceViewHolder private constructor(private val binding: ItemPickerChoiceBinding) :
DialogRecyclerView.ViewHolder(binding.root) {
fun bind(choice: PlaylistChoice, listener: ClickableListListener<PlaylistChoice>) {
listener.bind(choice, this)
binding.pickerImage.apply {
bind(choice.playlist)
isActivated = choice.alreadyAdded
}
binding.pickerName.text = choice.playlist.name.resolve(binding.context)
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
PlaylistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<PlaylistChoice>() {
override fun areContentsTheSame(oldItem: PlaylistChoice, newItem: PlaylistChoice) =
oldItem.playlist.name == newItem.playlist.name &&
oldItem.alreadyAdded == newItem.alreadyAdded
}
}
}

View file

@ -0,0 +1,232 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistPickerViewModel.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.picker
import android.content.Context
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
/**
* A [ViewModel] managing the state of the playlist picker dialogs.
*
* @author Alexander Capehart
*/
@HiltViewModel
class PlaylistPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
ViewModel(), MusicRepository.UpdateListener {
private val _currentPendingPlaylist = MutableStateFlow<PendingPlaylist?>(null)
/** A new [Playlist] having it's name chosen by the user. Null if none yet. */
val currentPendingPlaylist: StateFlow<PendingPlaylist?>
get() = _currentPendingPlaylist
private val _currentPlaylistToRename = MutableStateFlow<Playlist?>(null)
/** An existing [Playlist] that is being renamed. Null if none yet. */
val currentPlaylistToRename: StateFlow<Playlist?>
get() = _currentPlaylistToRename
private val _currentPlaylistToDelete = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] that needs it's deletion confirmed. Null if none yet. */
val currentPlaylistToDelete: StateFlow<Playlist?>
get() = _currentPlaylistToDelete
private val _chosenName = MutableStateFlow<ChosenName>(ChosenName.Empty)
/** The users chosen name for [currentPendingPlaylist] or [currentPlaylistToRename]. */
val chosenName: StateFlow<ChosenName>
get() = _chosenName
private val _currentSongsToAdd = MutableStateFlow<List<Song>?>(null)
/** A batch of [Song]s to add to a playlist chosen by the user. Null if none yet. */
val currentSongsToAdd: StateFlow<List<Song>?>
get() = _currentSongsToAdd
private val _playlistAddChoices = MutableStateFlow<List<PlaylistChoice>>(listOf())
/** The [Playlist]s that [currentSongsToAdd] could be added to. */
val playlistAddChoices: StateFlow<List<PlaylistChoice>>
get() = _playlistAddChoices
init {
musicRepository.addUpdateListener(this)
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
var refreshChoicesWith: List<Song>? = null
val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) {
_currentPendingPlaylist.value =
_currentPendingPlaylist.value?.let { pendingPlaylist ->
PendingPlaylist(
pendingPlaylist.preferredName,
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) })
}
_currentSongsToAdd.value =
_currentSongsToAdd.value?.let { pendingSongs ->
pendingSongs
.mapNotNull { deviceLibrary.findSong(it.uid) }
.ifEmpty { null }
.also { refreshChoicesWith = it }
}
}
val chosenName = _chosenName.value
if (changes.userLibrary) {
when (chosenName) {
is ChosenName.Valid -> updateChosenName(chosenName.value)
is ChosenName.AlreadyExists -> updateChosenName(chosenName.prior)
else -> {
// Nothing to do.
}
}
refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value
}
refreshChoicesWith?.let(::refreshPlaylistChoices)
}
override fun onCleared() {
musicRepository.removeUpdateListener(this)
}
/**
* Set a new [currentPendingPlaylist] from a new batch of pending [Song] [Music.UID]s.
*
* @param context [Context] required to generate a playlist name.
* @param songUids The [Music.UID]s of songs to be present in the playlist.
*/
fun setPendingPlaylist(context: Context, songUids: Array<Music.UID>) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
val songs = songUids.mapNotNull(deviceLibrary::findSong)
val userLibrary = musicRepository.userLibrary ?: return
var i = 1
while (true) {
val possibleName = context.getString(R.string.fmt_def_playlist, i)
if (userLibrary.playlists.none { it.name.resolve(context) == possibleName }) {
_currentPendingPlaylist.value = PendingPlaylist(possibleName, songs)
return
}
++i
}
}
/**
* Set a new [currentPlaylistToRename] from a [Playlist] [Music.UID].
*
* @param playlistUid The [Music.UID]s of the [Playlist] to rename.
*/
fun setPlaylistToRename(playlistUid: Music.UID) {
_currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
}
/**
* Set a new [currentPendingPlaylist] from a new [Playlist] [Music.UID].
*
* @param playlistUid The [Music.UID] of the [Playlist] to delete.
*/
fun setPlaylistToDelete(playlistUid: Music.UID) {
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
}
/**
* Update the current [chosenName] based on new user input.
*
* @param name The new user-inputted name, or null if not present.
*/
fun updateChosenName(name: String?) {
_chosenName.value =
when {
name.isNullOrEmpty() -> ChosenName.Empty
name.isBlank() -> ChosenName.Blank
else -> {
val trimmed = name.trim()
val userLibrary = musicRepository.userLibrary
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
ChosenName.Valid(trimmed)
} else {
ChosenName.AlreadyExists(trimmed)
}
}
}
}
/**
* Set a new [currentSongsToAdd] from a new batch of pending [Song] [Music.UID]s.
*
* @param songUids The [Music.UID]s of songs to add to a playlist.
*/
fun setSongsToAdd(songUids: Array<Music.UID>) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
val songs = songUids.mapNotNull(deviceLibrary::findSong)
_currentSongsToAdd.value = songs
refreshPlaylistChoices(songs)
}
private fun refreshPlaylistChoices(songs: List<Song>) {
val userLibrary = musicRepository.userLibrary ?: return
_playlistAddChoices.value =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
val songSet = it.songs.toSet()
PlaylistChoice(it, songs.all(songSet::contains))
}
}
}
/**
* Represents a playlist that will be created as soon as a name is chosen.
*
* @param preferredName The name to be used by default if no other name is chosen.
* @param songs The [Song]s to be contained in the [PendingPlaylist]
* @author Alexander Capehart (OxygenCobalt)
*/
data class PendingPlaylist(val preferredName: String, val songs: List<Song>)
/**
* Represents the (processed) user input from the playlist naming dialogs.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface ChosenName {
/** The current name is valid. */
data class Valid(val value: String) : ChosenName
/** The current name already exists. */
data class AlreadyExists(val prior: String) : ChosenName
/** The current name is empty. */
object Empty : ChosenName
/** The current name only consists of whitespace. */
object Blank : ChosenName
}
/**
* An individual [Playlist] choice to add [Song]s to.
*
* @param playlist The [Playlist] represented.
* @param alreadyAdded Whether the songs currently pending addition have already been added to the
* [Playlist].
* @author Alexander Capehart (OxygenCobalt)
*/
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean) : Item

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2023 Auxio Project
* RenamePlaylistDialog.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.picker
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A dialog allowing the name of a new playlist to be chosen before committing it to the database.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class RenamePlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>() {
private val musicModel: MusicViewModel by activityViewModels()
private val pickerModel: PlaylistPickerViewModel by viewModels()
// Information about what playlist to name for is initially within the navigation arguments
// as UIDs, as that is the only safe way to parcel playlist information.
private val args: RenamePlaylistDialogArgs by navArgs()
private var initializedField = false
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
.setTitle(R.string.lbl_rename_playlist)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
val playlist = unlikelyToBeNull(pickerModel.currentPlaylistToRename.value)
val chosenName = pickerModel.chosenName.value as ChosenName.Valid
musicModel.renamePlaylist(playlist, chosenName.value)
requireContext().showToast(R.string.lng_playlist_renamed)
findNavController().navigateUp()
}
.setNegativeButton(R.string.lbl_cancel, null)
}
override fun onCreateBinding(inflater: LayoutInflater) =
DialogPlaylistNameBinding.inflate(inflater)
override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) }
// --- VIEWMODEL SETUP ---
pickerModel.setPlaylistToRename(args.playlistUid)
collectImmediately(pickerModel.currentPlaylistToRename, ::updatePlaylistToRename)
collectImmediately(pickerModel.chosenName, ::updateChosenName)
}
private fun updatePlaylistToRename(playlist: Playlist?) {
if (playlist == null) {
// Nothing to rename anymore.
findNavController().navigateUp()
return
}
if (!initializedField) {
requireBinding().playlistName.setText(playlist.name.resolve(requireContext()))
initializedField = true
}
}
private fun updateChosenName(chosenName: ChosenName) {
(dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled =
chosenName is ChosenName.Valid
}
}

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