commit
9739e017f5
262 changed files with 8004 additions and 3624 deletions
4
.github/workflows/android.yml
vendored
4
.github/workflows/android.yml
vendored
|
@ -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
7
.gitmodules
vendored
|
@ -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
|
||||
|
|
28
CHANGELOG.md
28
CHANGELOG.md
|
@ -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
|
11
README.md
11
README.md
|
@ -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
|
||||
|
|
|
@ -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,14 +56,15 @@ 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'
|
||||
|
|
11
app/proguard-rules.pro
vendored
11
app/proguard-rules.pro
vendored
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: Library?) {
|
||||
if (library == null) {
|
||||
// Nothing to do.
|
||||
return
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
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 = library.sanitize(song)?.also(::refreshAudioInfo)
|
||||
_currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo)
|
||||
logD("Updated song to ${currentSong.value}")
|
||||
}
|
||||
|
||||
val album = currentAlbum.value
|
||||
if (album != null) {
|
||||
_currentAlbum.value = library.sanitize(album)?.also(::refreshAlbumList)
|
||||
logD("Updated genre to ${currentAlbum.value}")
|
||||
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
|
||||
logD("Updated album to ${currentAlbum.value}")
|
||||
}
|
||||
|
||||
val artist = currentArtist.value
|
||||
if (artist != null) {
|
||||
_currentArtist.value = library.sanitize(artist)?.also(::refreshArtistList)
|
||||
logD("Updated genre to ${currentArtist.value}")
|
||||
_currentArtist.value =
|
||||
deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
|
||||
logD("Updated artist to ${currentArtist.value}")
|
||||
}
|
||||
|
||||
val genre = currentGenre.value
|
||||
if (genre != null) {
|
||||
_currentGenre.value = library.sanitize(genre)?.also(::refreshGenreList)
|
||||
_currentGenre.value = deviceLibrary.findGenre(genre.uid)?.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.
|
||||
*
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,11 +124,13 @@ 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) =
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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() }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,12 +305,23 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,19 +350,20 @@ 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 {
|
||||
return
|
||||
}
|
||||
|
||||
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 -> {
|
||||
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.
|
||||
|
@ -378,11 +374,11 @@ class HomeFragment :
|
|||
requireNotNull(storagePermissionLauncher) {
|
||||
"Permission launcher was not available"
|
||||
}
|
||||
.launch(Indexer.PERMISSION_READ_AUDIO)
|
||||
.launch(PERMISSION_READ_AUDIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Indexer.NoMusicException -> {
|
||||
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.
|
||||
|
@ -404,28 +400,27 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,12 +455,16 @@ class HomeFragment :
|
|||
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
val binding = requireBinding()
|
||||
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
|
||||
selected.isNotEmpty()) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupAxisTransitions(axis: Int) {
|
||||
|
@ -499,6 +499,7 @@ class HomeFragment :
|
|||
MusicMode.ALBUMS -> AlbumListFragment()
|
||||
MusicMode.ARTISTS -> ArtistListFragment()
|
||||
MusicMode.GENRES -> GenreListFragment()
|
||||
MusicMode.PLAYLISTS -> PlaylistListFragment()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()) }
|
||||
|
|
|
@ -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>) {
|
||||
|
|
|
@ -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>) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
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.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: Library?) {
|
||||
if (library == null) {
|
||||
return
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
|
|
87
app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
Normal file
87
app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
Normal 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"
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
/**
|
||||
* Called when the current [Library] has changed.
|
||||
* Add an [IndexingListener] to receive updates from this instance.
|
||||
*
|
||||
* @param library The new [Library], or null if no [Library] has been loaded yet.
|
||||
* @param listener The [UpdateListener] to add.
|
||||
*/
|
||||
fun onLibraryChanged(library: Library?)
|
||||
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 a change to the stored music information occurs.
|
||||
*
|
||||
* @param changes The [Changes] that have occurred.
|
||||
*/
|
||||
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 addListener(listener: MusicRepository.Listener) {
|
||||
listener.onLibraryChanged(library)
|
||||
listeners.add(listener)
|
||||
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 removeListener(listener: MusicRepository.Listener) {
|
||||
listeners.remove(listener)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (!changes.deviceLibrary) return
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
_statistics.value =
|
||||
Statistics(
|
||||
library.songs.size,
|
||||
library.albums.size,
|
||||
library.artists.size,
|
||||
library.genres.size,
|
||||
library.songs.sumOf { it.durationMs })
|
||||
deviceLibrary.songs.size,
|
||||
deviceLibrary.albums.size,
|
||||
deviceLibrary.artists.size,
|
||||
deviceLibrary.genres.size,
|
||||
deviceLibrary.songs.sumOf { it.durationMs })
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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.*
|
||||
|
||||
/**
|
||||
|
|
|
@ -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.
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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?) =
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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>
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
|
@ -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)
|
||||
}
|
219
app/src/main/java/org/oxycblt/auxio/music/info/Name.kt
Normal file
219
app/src/main/java/org/oxycblt/auxio/music/info/Name.kt
Normal 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+)") }
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
Loading…
Reference in a new issue