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
|
uses: actions/checkout@v3
|
||||||
- name: Clone submodules
|
- name: Clone submodules
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
- name: Set up JDK 11
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
java-version: '11'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
cache: gradle
|
cache: gradle
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
|
|
|
||||||
7
.gitmodules
vendored
7
.gitmodules
vendored
|
|
@ -1,4 +1,3 @@
|
||||||
[submodule "ExoPlayer"]
|
[submodule "media"]
|
||||||
path = ExoPlayer
|
path = media
|
||||||
url = https://github.com/OxygenCobalt/ExoPlayer.git
|
url = https://github.com/OxygenCobalt/media.git
|
||||||
branch = auxio
|
|
||||||
|
|
|
||||||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -1,5 +1,27 @@
|
||||||
# Changelog
|
# 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
|
## 3.0.5
|
||||||
|
|
||||||
#### What's Fixed
|
#### What's Fixed
|
||||||
|
|
@ -11,23 +33,23 @@ screen
|
||||||
## 3.0.4
|
## 3.0.4
|
||||||
|
|
||||||
#### What's New
|
#### What's New
|
||||||
- Added support for `COMPILATION` and `ITUNESCOMPILATION` flags.
|
- Added support for `COMPILATION` and `ITUNESCOMPILATION` flags
|
||||||
|
|
||||||
#### What's Improved
|
#### What's Improved
|
||||||
- Accept `REPLAYGAIN_*` adjustment information on OPUS files alongside
|
- Accept `REPLAYGAIN_*` adjustment information on OPUS files alongside
|
||||||
`R128_*` adjustments
|
`R128_*` adjustments
|
||||||
- List updates are now consistent across the app
|
- List updates are now consistent across the app
|
||||||
- Fixed jarring header update in detail view
|
- 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
|
- Audio effect (equalizer) session is now broadcast when playing/pausing
|
||||||
rather than on start/stop
|
rather than on start/stop
|
||||||
- Searching now ignores punctuation
|
|
||||||
- Numeric names are now logically sorted (i.e 7 before 15)
|
- Numeric names are now logically sorted (i.e 7 before 15)
|
||||||
|
|
||||||
#### What's Fixed
|
#### What's Fixed
|
||||||
- Fixed MP4-AAC files not playing due to an accidental audio extractor
|
- Fixed MP4-AAC files not playing due to an accidental audio extractor
|
||||||
deletion
|
deletion
|
||||||
- Fix "format" not appearing in song properties view
|
- Fix "format" not appearing in song properties view
|
||||||
|
- Fix visual bugs when editing duplicate songs in the queue
|
||||||
|
|
||||||
#### What's Changed
|
#### What's Changed
|
||||||
- "Ignore articles when sorting" is now "Intelligent sorting"
|
- "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>
|
<h1 align="center"><b>Auxio</b></h1>
|
||||||
<h4 align="center">A simple, rational music player for android.</h4>
|
<h4 align="center">A simple, rational music player for android.</h4>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.5">
|
<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.0.5&color=64B5F6&style=flat">
|
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.1.0&color=64B5F6&style=flat">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
|
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
## About
|
## 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.
|
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
|
## Features
|
||||||
|
|
||||||
- [ExoPlayer](https://exoplayer.dev/) based playback
|
- [ExoPlayer](https://exoplayer.dev/)-based playback
|
||||||
- Snappy UI derived from the latest Material Design guidelines
|
- Snappy UI derived from the latest Material Design guidelines
|
||||||
- Opinionated UX that prioritizes ease of use over edge cases
|
- Opinionated UX that prioritizes ease of use over edge cases
|
||||||
- Customizable behavior
|
- 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
|
precise/original dates, sort tags, and more
|
||||||
- Advanced artist system that unifies artists and album artists
|
- Advanced artist system that unifies artists and album artists
|
||||||
- SD Card-aware folder management
|
- 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)
|
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
|
||||||
- External equalizer support (ex. Wavelet)
|
- External equalizer support (ex. Wavelet)
|
||||||
- Edge-to-edge
|
- Edge-to-edge
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId namespace
|
applicationId namespace
|
||||||
versionName "3.0.5"
|
versionName "3.1.0"
|
||||||
versionCode 29
|
versionCode 30
|
||||||
|
|
||||||
minSdk 21
|
minSdk 21
|
||||||
targetSdk 33
|
targetSdk 33
|
||||||
|
|
@ -30,12 +30,12 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_11
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_11
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "11"
|
jvmTarget = "17"
|
||||||
freeCompilerArgs += "-Xjvm-default=all"
|
freeCompilerArgs += "-Xjvm-default=all"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,15 +56,16 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
exclude "DebugProbesKt.bin"
|
jniLibs {
|
||||||
exclude "kotlin-tooling-metadata.json"
|
excludes += ['**/kotlin/**', '**/okhttp3/**']
|
||||||
exclude "**/kotlin/**"
|
}
|
||||||
exclude "**/okhttp3/**"
|
resources {
|
||||||
exclude "META-INF/*.version"
|
excludes += ['DebugProbesKt.bin', 'kotlin-tooling-metadata.json', '**/kotlin/**', '**/okhttp3/**', 'META-INF/*.version']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
}
|
}
|
||||||
|
|
@ -75,26 +76,27 @@ dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-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 ---
|
// --- SUPPORT ---
|
||||||
|
|
||||||
// General
|
// 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.appcompat:appcompat:1.6.1"
|
||||||
implementation "androidx.core:core-ktx:1.9.0"
|
implementation "androidx.core:core-ktx:1.10.1"
|
||||||
implementation "androidx.activity:activity-ktx:1.6.1"
|
implementation "androidx.activity:activity-ktx:1.7.1"
|
||||||
implementation "androidx.fragment:fragment-ktx:1.5.5"
|
implementation "androidx.fragment:fragment-ktx:1.5.7"
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
implementation "androidx.recyclerview:recyclerview:1.3.0"
|
implementation "androidx.recyclerview:recyclerview:1.3.0"
|
||||||
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||||
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
|
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
|
||||||
implementation 'androidx.core:core-ktx:1.9.0'
|
implementation 'androidx.core:core-ktx:1.10.1'
|
||||||
|
|
||||||
// Lifecycle
|
// 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:$lifecycle_version"
|
||||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||||
|
|
@ -111,7 +113,7 @@ dependencies {
|
||||||
implementation "androidx.preference:preference-ktx:1.2.0"
|
implementation "androidx.preference:preference-ktx:1.2.0"
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
def room_version = '2.5.0'
|
def room_version = '2.6.0-alpha01'
|
||||||
implementation "androidx.room:room-runtime:$room_version"
|
implementation "androidx.room:room-runtime:$room_version"
|
||||||
kapt "androidx.room:room-compiler:$room_version"
|
kapt "androidx.room:room-compiler:$room_version"
|
||||||
implementation "androidx.room:room-ktx:$room_version"
|
implementation "androidx.room:room-ktx:$room_version"
|
||||||
|
|
@ -119,27 +121,27 @@ dependencies {
|
||||||
// --- THIRD PARTY ---
|
// --- THIRD PARTY ---
|
||||||
|
|
||||||
// Exoplayer (Vendored)
|
// Exoplayer (Vendored)
|
||||||
implementation project(":exoplayer-library-core")
|
implementation project(":media-lib-exoplayer")
|
||||||
implementation project(":exoplayer-extension-ffmpeg")
|
implementation project(":media-lib-decoder-ffmpeg")
|
||||||
|
|
||||||
// Image loading
|
// Image loading
|
||||||
implementation 'io.coil-kt:coil-base:2.2.2'
|
implementation 'io.coil-kt:coil-base:2.3.0'
|
||||||
|
|
||||||
// Material
|
// 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
|
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
|
||||||
// PR a fix.
|
// PR a fix.
|
||||||
implementation "com.google.android.material:material:1.8.0-alpha01"
|
implementation "com.google.android.material:material:1.8.0-alpha01"
|
||||||
|
|
||||||
// Dependency Injection
|
// Dependency Injection
|
||||||
def dagger_version = '2.45'
|
implementation "com.google.dagger:dagger:$hilt_version"
|
||||||
implementation "com.google.dagger:dagger:$dagger_version"
|
kapt "com.google.dagger:dagger-compiler:$hilt_version"
|
||||||
kapt "com.google.dagger:dagger-compiler:$dagger_version"
|
|
||||||
implementation "com.google.dagger:hilt-android:$hilt_version"
|
implementation "com.google.dagger:hilt-android:$hilt_version"
|
||||||
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
|
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
|
||||||
testImplementation "junit:junit:4.13.2"
|
testImplementation "junit:junit:4.13.2"
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
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.
|
# 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.
|
# Also it's easier to fix issues if the stack trace symbols remain unmangled.
|
||||||
-dontobfuscate
|
-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
|
@Test
|
||||||
fun useAppContext() {
|
fun useAppContext() {
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
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 androidx.core.graphics.drawable.IconCompat
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import org.oxycblt.auxio.home.HomeSettings
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
|
|
@ -39,6 +40,7 @@ class Auxio : Application() {
|
||||||
@Inject lateinit var imageSettings: ImageSettings
|
@Inject lateinit var imageSettings: ImageSettings
|
||||||
@Inject lateinit var playbackSettings: PlaybackSettings
|
@Inject lateinit var playbackSettings: PlaybackSettings
|
||||||
@Inject lateinit var uiSettings: UISettings
|
@Inject lateinit var uiSettings: UISettings
|
||||||
|
@Inject lateinit var homeSettings: HomeSettings
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
@ -46,6 +48,7 @@ class Auxio : Application() {
|
||||||
imageSettings.migrate()
|
imageSettings.migrate()
|
||||||
playbackSettings.migrate()
|
playbackSettings.migrate()
|
||||||
uiSettings.migrate()
|
uiSettings.migrate()
|
||||||
|
homeSettings.migrate()
|
||||||
// Adding static shortcuts in a dynamic manner is better than declaring them
|
// Adding static shortcuts in a dynamic manner is better than declaring them
|
||||||
// manually, as it will properly handle the difference between debug and release
|
// manually, as it will properly handle the difference between debug and release
|
||||||
// Auxio instances.
|
// Auxio instances.
|
||||||
|
|
|
||||||
|
|
@ -33,18 +33,26 @@ object IntegerTable {
|
||||||
const val VIEW_TYPE_ARTIST = 0xA002
|
const val VIEW_TYPE_ARTIST = 0xA002
|
||||||
/** GenreViewHolder */
|
/** GenreViewHolder */
|
||||||
const val VIEW_TYPE_GENRE = 0xA003
|
const val VIEW_TYPE_GENRE = 0xA003
|
||||||
|
/** PlaylistViewHolder */
|
||||||
|
const val VIEW_TYPE_PLAYLIST = 0xA004
|
||||||
/** BasicHeaderViewHolder */
|
/** BasicHeaderViewHolder */
|
||||||
const val VIEW_TYPE_BASIC_HEADER = 0xA004
|
const val VIEW_TYPE_BASIC_HEADER = 0xA005
|
||||||
|
/** DividerViewHolder */
|
||||||
|
const val VIEW_TYPE_DIVIDER = 0xA006
|
||||||
/** SortHeaderViewHolder */
|
/** SortHeaderViewHolder */
|
||||||
const val VIEW_TYPE_SORT_HEADER = 0xA005
|
const val VIEW_TYPE_SORT_HEADER = 0xA007
|
||||||
/** AlbumSongViewHolder */
|
/** AlbumSongViewHolder */
|
||||||
const val VIEW_TYPE_ALBUM_SONG = 0xA007
|
const val VIEW_TYPE_ALBUM_SONG = 0xA008
|
||||||
/** ArtistAlbumViewHolder */
|
/** ArtistAlbumViewHolder */
|
||||||
const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
|
const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
|
||||||
/** ArtistSongViewHolder */
|
/** ArtistSongViewHolder */
|
||||||
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
|
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
|
||||||
/** DiscHeaderViewHolder */
|
/** 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 */
|
/** "Music playback" notification code */
|
||||||
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
|
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
|
||||||
/** "Music loading" notification code */
|
/** "Music loading" notification code */
|
||||||
|
|
@ -65,34 +73,36 @@ object IntegerTable {
|
||||||
const val PLAYBACK_MODE_IN_ALBUM = 0xA105
|
const val PLAYBACK_MODE_IN_ALBUM = 0xA105
|
||||||
/** PlaybackMode.ALL_SONGS */
|
/** PlaybackMode.ALL_SONGS */
|
||||||
const val PLAYBACK_MODE_ALL_SONGS = 0xA106
|
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 */
|
/** MusicMode.SONGS */
|
||||||
const val MUSIC_MODE_SONGS = 0xA10B
|
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
|
const val SORT_BY_NAME = 0xA10C
|
||||||
/** Sort.ByArtist */
|
/** Sort.Mode.ByArtist */
|
||||||
const val SORT_BY_ARTIST = 0xA10D
|
const val SORT_BY_ARTIST = 0xA10D
|
||||||
/** Sort.ByAlbum */
|
/** Sort.Mode.ByAlbum */
|
||||||
const val SORT_BY_ALBUM = 0xA10E
|
const val SORT_BY_ALBUM = 0xA10E
|
||||||
/** Sort.ByYear */
|
/** Sort.Mode.ByYear */
|
||||||
const val SORT_BY_YEAR = 0xA10F
|
const val SORT_BY_YEAR = 0xA10F
|
||||||
/** Sort.ByDuration */
|
/** Sort.Mode.ByDuration */
|
||||||
const val SORT_BY_DURATION = 0xA114
|
const val SORT_BY_DURATION = 0xA114
|
||||||
/** Sort.ByCount */
|
/** Sort.Mode.ByCount */
|
||||||
const val SORT_BY_COUNT = 0xA115
|
const val SORT_BY_COUNT = 0xA115
|
||||||
/** Sort.ByDisc */
|
/** Sort.Mode.ByDisc */
|
||||||
const val SORT_BY_DISC = 0xA116
|
const val SORT_BY_DISC = 0xA116
|
||||||
/** Sort.ByTrack */
|
/** Sort.Mode.ByTrack */
|
||||||
const val SORT_BY_TRACK = 0xA117
|
const val SORT_BY_TRACK = 0xA117
|
||||||
/** Sort.ByDateAdded */
|
/** Sort.Mode.ByDateAdded */
|
||||||
const val SORT_BY_DATE_ADDED = 0xA118
|
const val SORT_BY_DATE_ADDED = 0xA118
|
||||||
|
/** Sort.Mode.None */
|
||||||
|
const val SORT_BY_NONE = 0xA11F
|
||||||
/** ReplayGainMode.Off (No longer used but still reserved) */
|
/** ReplayGainMode.Off (No longer used but still reserved) */
|
||||||
// const val REPLAY_GAIN_MODE_OFF = 0xA110
|
// const val REPLAY_GAIN_MODE_OFF = 0xA110
|
||||||
/** ReplayGainMode.Track */
|
/** 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: Use proper material attributes (Not the weird dimen attributes I currently have)
|
||||||
* TODO: Migrate to material animation system
|
* TODO: Migrate to material animation system
|
||||||
* TODO: Unit testing
|
* 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
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,17 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||||
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.Music
|
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.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.PlaybackBottomSheetBehavior
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
|
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.ui.ViewBindingFragment
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
|
|
@ -60,9 +63,11 @@ class MainFragment :
|
||||||
ViewBindingFragment<FragmentMainBinding>(),
|
ViewBindingFragment<FragmentMainBinding>(),
|
||||||
ViewTreeObserver.OnPreDrawListener,
|
ViewTreeObserver.OnPreDrawListener,
|
||||||
NavController.OnDestinationChangedListener {
|
NavController.OnDestinationChangedListener {
|
||||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
|
||||||
private val navModel: NavigationViewModel 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 selectionModel: SelectionViewModel by activityViewModels()
|
||||||
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
private val callback = DynamicBackPressedCallback()
|
private val callback = DynamicBackPressedCallback()
|
||||||
private var lastInsets: WindowInsets? = null
|
private var lastInsets: WindowInsets? = null
|
||||||
private var elevationNormal = 0f
|
private var elevationNormal = 0f
|
||||||
|
|
@ -132,6 +137,10 @@ class MainFragment :
|
||||||
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
|
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
|
||||||
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
|
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
|
||||||
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
|
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)
|
collectImmediately(playbackModel.song, ::updateSong)
|
||||||
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
|
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
|
||||||
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
|
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
|
||||||
|
|
@ -258,7 +267,7 @@ class MainFragment :
|
||||||
initialNavDestinationChange = true
|
initialNavDestinationChange = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selectionModel.consume()
|
selectionModel.drop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleMainNavigation(action: MainNavigationAction?) {
|
private fun handleMainNavigation(action: MainNavigationAction?) {
|
||||||
|
|
@ -268,8 +277,8 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
when (action) {
|
when (action) {
|
||||||
is MainNavigationAction.Expand -> tryExpandSheets()
|
is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel()
|
||||||
is MainNavigationAction.Collapse -> tryCollapseSheets()
|
is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel()
|
||||||
is MainNavigationAction.Directions ->
|
is MainNavigationAction.Directions ->
|
||||||
findNavController().navigateSafe(action.directions)
|
findNavController().navigateSafe(action.directions)
|
||||||
}
|
}
|
||||||
|
|
@ -279,7 +288,7 @@ class MainFragment :
|
||||||
|
|
||||||
private fun handleExploreNavigation(item: Music?) {
|
private fun handleExploreNavigation(item: Music?) {
|
||||||
if (item != null) {
|
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?) {
|
private fun handlePlaybackArtistPicker(song: Song?) {
|
||||||
if (song != null) {
|
if (song != null) {
|
||||||
navModel.mainNavigateTo(
|
navModel.mainNavigateTo(
|
||||||
|
|
@ -318,22 +361,33 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryExpandSheets() {
|
private fun tryOpenPlaybackPanel() {
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
|
|
||||||
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
||||||
// Playback sheet is not expanded and not hidden, we can expand it.
|
// Playback sheet is not expanded and not hidden, we can expand it.
|
||||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
|
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 binding = requireBinding()
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
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 =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
|
|
@ -406,8 +460,13 @@ class MainFragment :
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear out pending playlist edits.
|
||||||
|
if (detailModel.dropPlaylistEdit()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Clear out any prior selections.
|
// Clear out any prior selections.
|
||||||
if (selectionModel.consume().isNotEmpty()) {
|
if (selectionModel.drop()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,6 +494,7 @@ class MainFragment :
|
||||||
isEnabled =
|
isEnabled =
|
||||||
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
||||||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
||||||
|
detailModel.editedPlaylist.value != null ||
|
||||||
selectionModel.selected.value.isNotEmpty() ||
|
selectionModel.selected.value.isNotEmpty() ||
|
||||||
exploreNavController.currentDestination?.id !=
|
exploreNavController.currentDestination?.id !=
|
||||||
exploreNavController.graph.startDestinationId
|
exploreNavController.graph.startDestinationId
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.ConcatAdapter
|
import androidx.recyclerview.widget.ConcatAdapter
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
@ -34,6 +35,8 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||||
import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter
|
import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter
|
||||||
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
|
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
|
||||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
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.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
|
|
@ -43,9 +46,11 @@ import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Song
|
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.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,6 +66,7 @@ class AlbumDetailFragment :
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
override val navModel: NavigationViewModel by activityViewModels()
|
override val navModel: NavigationViewModel by activityViewModels()
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
// Information about what album to display is initially within the navigation arguments
|
// Information about what album to display is initially within the navigation arguments
|
||||||
// as a UID, as that is the only safe way to parcel an album.
|
// as a UID, as that is the only safe way to parcel an album.
|
||||||
|
|
@ -87,17 +93,27 @@ class AlbumDetailFragment :
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
// --- UI SETUP --
|
// --- UI SETUP --
|
||||||
binding.detailToolbar.apply {
|
binding.detailNormalToolbar.apply {
|
||||||
inflateMenu(R.menu.menu_album_detail)
|
inflateMenu(R.menu.menu_album_detail)
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||||
setOnMenuItemClickListener(this@AlbumDetailFragment)
|
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 ---
|
// -- VIEWMODEL SETUP ---
|
||||||
// DetailViewModel handles most initialization from the navigation argument.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setAlbumUid(args.albumUid)
|
detailModel.setAlbum(args.albumUid)
|
||||||
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
|
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
|
||||||
collectImmediately(detailModel.albumList, ::updateList)
|
collectImmediately(detailModel.albumList, ::updateList)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
|
|
@ -108,7 +124,7 @@ class AlbumDetailFragment :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.detailToolbar.setOnMenuItemClickListener(null)
|
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
binding.detailRecycler.adapter = null
|
binding.detailRecycler.adapter = null
|
||||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
// 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.
|
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||||
|
|
@ -136,6 +152,10 @@ class AlbumDetailFragment :
|
||||||
onNavigateToParentArtist()
|
onNavigateToParentArtist()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(currentAlbum)
|
||||||
|
true
|
||||||
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -159,8 +179,10 @@ class AlbumDetailFragment :
|
||||||
|
|
||||||
override fun onOpenSortMenu(anchor: View) {
|
override fun onOpenSortMenu(anchor: View) {
|
||||||
openMenu(anchor, R.menu.menu_album_sort) {
|
openMenu(anchor, R.menu.menu_album_sort) {
|
||||||
|
// Select the corresponding sort mode option
|
||||||
val sort = detailModel.albumSongSort
|
val sort = detailModel.albumSongSort
|
||||||
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
||||||
|
// Select the corresponding sort direction option
|
||||||
val directionItemId =
|
val directionItemId =
|
||||||
when (sort.direction) {
|
when (sort.direction) {
|
||||||
Sort.Direction.ASCENDING -> R.id.option_sort_asc
|
Sort.Direction.ASCENDING -> R.id.option_sort_asc
|
||||||
|
|
@ -171,8 +193,10 @@ class AlbumDetailFragment :
|
||||||
item.isChecked = !item.isChecked
|
item.isChecked = !item.isChecked
|
||||||
detailModel.albumSongSort =
|
detailModel.albumSongSort =
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
|
// Sort direction options
|
||||||
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
|
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
|
||||||
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
|
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)))
|
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
|
|
@ -190,7 +214,7 @@ class AlbumDetailFragment :
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requireBinding().detailToolbar.title = album.resolveName(requireContext())
|
requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
|
||||||
albumHeaderAdapter.setParent(album)
|
albumHeaderAdapter.setParent(album)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,6 +313,13 @@ class AlbumDetailFragment :
|
||||||
|
|
||||||
private fun updateSelection(selected: List<Music>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
albumListAdapter.setSelected(selected.toSet())
|
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.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.ConcatAdapter
|
import androidx.recyclerview.widget.ConcatAdapter
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
|
@ -34,6 +35,8 @@ import org.oxycblt.auxio.detail.header.ArtistDetailHeaderAdapter
|
||||||
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
||||||
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
|
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
|
||||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
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.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
|
|
@ -42,9 +45,10 @@ import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -60,6 +64,7 @@ class ArtistDetailFragment :
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
override val navModel: NavigationViewModel by activityViewModels()
|
override val navModel: NavigationViewModel by activityViewModels()
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
// Information about what artist to display is initially within the navigation arguments
|
// Information about what artist to display is initially within the navigation arguments
|
||||||
// as a UID, as that is the only safe way to parcel an artist.
|
// as a UID, as that is the only safe way to parcel an artist.
|
||||||
|
|
@ -86,18 +91,31 @@ class ArtistDetailFragment :
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
binding.detailToolbar.apply {
|
binding.detailNormalToolbar.apply {
|
||||||
inflateMenu(R.menu.menu_genre_artist_detail)
|
inflateMenu(R.menu.menu_parent_detail)
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||||
setOnMenuItemClickListener(this@ArtistDetailFragment)
|
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 ---
|
// --- VIEWMODEL SETUP ---
|
||||||
// DetailViewModel handles most initialization from the navigation argument.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setArtistUid(args.artistUid)
|
detailModel.setArtist(args.artistUid)
|
||||||
collectImmediately(detailModel.currentArtist, ::updateItem)
|
collectImmediately(detailModel.currentArtist, ::updateArtist)
|
||||||
collectImmediately(detailModel.artistList, ::updateList)
|
collectImmediately(detailModel.artistList, ::updateList)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
|
|
@ -107,7 +125,7 @@ class ArtistDetailFragment :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.detailToolbar.setOnMenuItemClickListener(null)
|
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
binding.detailRecycler.adapter = null
|
binding.detailRecycler.adapter = null
|
||||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
// 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.
|
// 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)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(currentArtist)
|
||||||
|
true
|
||||||
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -171,8 +193,10 @@ class ArtistDetailFragment :
|
||||||
|
|
||||||
override fun onOpenSortMenu(anchor: View) {
|
override fun onOpenSortMenu(anchor: View) {
|
||||||
openMenu(anchor, R.menu.menu_artist_sort) {
|
openMenu(anchor, R.menu.menu_artist_sort) {
|
||||||
|
// Select the corresponding sort mode option
|
||||||
val sort = detailModel.artistSongSort
|
val sort = detailModel.artistSongSort
|
||||||
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
||||||
|
// Select the corresponding sort direction option
|
||||||
val directionItemId =
|
val directionItemId =
|
||||||
when (sort.direction) {
|
when (sort.direction) {
|
||||||
Sort.Direction.ASCENDING -> R.id.option_sort_asc
|
Sort.Direction.ASCENDING -> R.id.option_sort_asc
|
||||||
|
|
@ -184,8 +208,10 @@ class ArtistDetailFragment :
|
||||||
|
|
||||||
detailModel.artistSongSort =
|
detailModel.artistSongSort =
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
|
// Sort direction options
|
||||||
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
|
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
|
||||||
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
|
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)))
|
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) {
|
if (artist == null) {
|
||||||
// Artist we were showing no longer exists.
|
// Artist we were showing no longer exists.
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requireBinding().detailToolbar.title = artist.resolveName(requireContext())
|
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
|
||||||
artistHeaderAdapter.setParent(artist)
|
artistHeaderAdapter.setParent(artist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,6 +286,13 @@ class ArtistDetailFragment :
|
||||||
|
|
||||||
private fun updateSelection(selected: List<Music>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
artistListAdapter.setSelected(selected.toSet())
|
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
|
* and thus scrolling past them should make the toolbar show the name in order to give context on
|
||||||
* where the user currently is.
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class DetailAppBarLayout
|
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
|
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
|
||||||
// used within the detail layouts.
|
// 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
|
// 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.
|
// 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.launch
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.detail.list.EditHeader
|
||||||
import org.oxycblt.auxio.detail.list.SortHeader
|
import org.oxycblt.auxio.detail.list.SortHeader
|
||||||
import org.oxycblt.auxio.list.BasicHeader
|
import org.oxycblt.auxio.list.BasicHeader
|
||||||
|
import org.oxycblt.auxio.list.Divider
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.metadata.AudioInfo
|
import org.oxycblt.auxio.music.info.Disc
|
||||||
import org.oxycblt.auxio.music.metadata.Disc
|
import org.oxycblt.auxio.music.info.ReleaseType
|
||||||
import org.oxycblt.auxio.music.metadata.ReleaseType
|
import org.oxycblt.auxio.music.metadata.AudioProperties
|
||||||
import org.oxycblt.auxio.music.model.Library
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
|
|
@ -54,22 +55,22 @@ class DetailViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val musicRepository: MusicRepository,
|
private val musicRepository: MusicRepository,
|
||||||
private val audioInfoProvider: AudioInfo.Provider,
|
private val audioPropertiesFactory: AudioProperties.Factory,
|
||||||
private val musicSettings: MusicSettings,
|
private val musicSettings: MusicSettings,
|
||||||
private val playbackSettings: PlaybackSettings
|
private val playbackSettings: PlaybackSettings
|
||||||
) : ViewModel(), MusicRepository.Listener {
|
) : ViewModel(), MusicRepository.UpdateListener {
|
||||||
private var currentSongJob: Job? = null
|
|
||||||
|
|
||||||
// --- SONG ---
|
// --- SONG ---
|
||||||
|
|
||||||
|
private var currentSongJob: Job? = null
|
||||||
|
|
||||||
private val _currentSong = MutableStateFlow<Song?>(null)
|
private val _currentSong = MutableStateFlow<Song?>(null)
|
||||||
/** The current [Song] to display. Null if there is nothing to show. */
|
/** The current [Song] to display. Null if there is nothing to show. */
|
||||||
val currentSong: StateFlow<Song?>
|
val currentSong: StateFlow<Song?>
|
||||||
get() = _currentSong
|
get() = _currentSong
|
||||||
|
|
||||||
private val _songAudioInfo = MutableStateFlow<AudioInfo?>(null)
|
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
|
||||||
/** The [AudioInfo] of the currently shown [Song]. Null if not loaded yet. */
|
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
|
||||||
val songAudioInfo: StateFlow<AudioInfo?> = _songAudioInfo
|
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
|
||||||
|
|
||||||
// --- ALBUM ---
|
// --- ALBUM ---
|
||||||
|
|
||||||
|
|
@ -144,6 +145,29 @@ constructor(
|
||||||
currentGenre.value?.let { refreshGenreList(it, true) }
|
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
|
* The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently
|
||||||
* shown item.
|
* shown item.
|
||||||
|
|
@ -152,126 +176,218 @@ constructor(
|
||||||
get() = playbackSettings.inParentPlaybackMode
|
get() = playbackSettings.inParentPlaybackMode
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
musicRepository.removeListener(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: Library?) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (library == null) {
|
|
||||||
// Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we are showing any item right now, we will need to refresh it (and any information
|
// 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
|
// related to it) with the new library in order to prevent stale items from showing up
|
||||||
// in the UI.
|
// in the UI.
|
||||||
|
val deviceLibrary = musicRepository.deviceLibrary
|
||||||
|
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||||
|
val song = currentSong.value
|
||||||
|
if (song != null) {
|
||||||
|
_currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo)
|
||||||
|
logD("Updated song to ${currentSong.value}")
|
||||||
|
}
|
||||||
|
|
||||||
val song = currentSong.value
|
val album = currentAlbum.value
|
||||||
if (song != null) {
|
if (album != null) {
|
||||||
_currentSong.value = library.sanitize(song)?.also(::refreshAudioInfo)
|
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
|
||||||
logD("Updated song to ${currentSong.value}")
|
logD("Updated album to ${currentAlbum.value}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val artist = currentArtist.value
|
||||||
|
if (artist != null) {
|
||||||
|
_currentArtist.value =
|
||||||
|
deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
|
||||||
|
logD("Updated artist to ${currentArtist.value}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val genre = currentGenre.value
|
||||||
|
if (genre != null) {
|
||||||
|
_currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList)
|
||||||
|
logD("Updated genre to ${currentGenre.value}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val album = currentAlbum.value
|
val userLibrary = musicRepository.userLibrary
|
||||||
if (album != null) {
|
if (changes.userLibrary && userLibrary != null) {
|
||||||
_currentAlbum.value = library.sanitize(album)?.also(::refreshAlbumList)
|
val playlist = currentPlaylist.value
|
||||||
logD("Updated genre to ${currentAlbum.value}")
|
if (playlist != null) {
|
||||||
}
|
logD("Updated playlist to ${currentPlaylist.value}")
|
||||||
|
_currentPlaylist.value =
|
||||||
val artist = currentArtist.value
|
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
|
||||||
if (artist != null) {
|
}
|
||||||
_currentArtist.value = library.sanitize(artist)?.also(::refreshArtistList)
|
|
||||||
logD("Updated genre to ${currentArtist.value}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val genre = currentGenre.value
|
|
||||||
if (genre != null) {
|
|
||||||
_currentGenre.value = library.sanitize(genre)?.also(::refreshGenreList)
|
|
||||||
logD("Updated genre to ${currentGenre.value}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and
|
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
|
||||||
* [songAudioInfo] will be updated to align with the new [Song].
|
* be updated to align with the new [Song].
|
||||||
*
|
*
|
||||||
* @param uid The UID of the [Song] to load. Must be valid.
|
* @param uid The UID of the [Song] to load. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setSongUid(uid: Music.UID) {
|
fun setSong(uid: Music.UID) {
|
||||||
if (_currentSong.value?.uid == uid) {
|
|
||||||
// Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logD("Opening Song [uid: $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]
|
* Set a new [currentAlbum] from it's [Music.UID]. [currentAlbum] and [albumList] will be
|
||||||
* and [albumList] will be updated to align with the new [Album].
|
* updated to align with the new [Album].
|
||||||
*
|
*
|
||||||
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
|
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setAlbumUid(uid: Music.UID) {
|
fun setAlbum(uid: Music.UID) {
|
||||||
if (_currentAlbum.value?.uid == uid) {
|
|
||||||
// Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logD("Opening Album [uid: $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]
|
* Set a new [currentArtist] from it's [Music.UID]. [currentArtist] and [artistList] will be
|
||||||
* and [artistList] will be updated to align with the new [Artist].
|
* updated to align with the new [Artist].
|
||||||
*
|
*
|
||||||
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
|
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setArtistUid(uid: Music.UID) {
|
fun setArtist(uid: Music.UID) {
|
||||||
if (_currentArtist.value?.uid == uid) {
|
|
||||||
// Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logD("Opening Artist [uid: $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]
|
* Set a new [currentGenre] from it's [Music.UID]. [currentGenre] and [genreList] will be
|
||||||
* and [genreList] will be updated to align with the new album.
|
* updated to align with the new album.
|
||||||
*
|
*
|
||||||
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
|
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setGenreUid(uid: Music.UID) {
|
fun setGenre(uid: Music.UID) {
|
||||||
if (_currentGenre.value?.uid == uid) {
|
|
||||||
// Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logD("Opening Genre [uid: $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) {
|
private fun refreshAudioInfo(song: Song) {
|
||||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||||
currentSongJob?.cancel()
|
currentSongJob?.cancel()
|
||||||
_songAudioInfo.value = null
|
_songAudioProperties.value = null
|
||||||
currentSongJob =
|
currentSongJob =
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val info = audioInfoProvider.extract(song)
|
val info = audioPropertiesFactory.extract(song)
|
||||||
yield()
|
yield()
|
||||||
_songAudioInfo.value = info
|
_songAudioProperties.value = info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
|
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
|
||||||
logD("Refreshing album data")
|
logD("Refreshing album list")
|
||||||
val list = mutableListOf<Item>()
|
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 =
|
val instructions =
|
||||||
if (replace) {
|
if (replace) {
|
||||||
// Intentional so that the header item isn't replaced with the songs
|
// 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) {
|
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
|
||||||
logD("Refreshing artist data")
|
logD("Refreshing artist list")
|
||||||
val list = mutableListOf<Item>()
|
val list = mutableListOf<Item>()
|
||||||
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
|
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
|
||||||
|
|
||||||
|
|
@ -329,7 +445,9 @@ constructor(
|
||||||
logD("Release groups for this artist: ${byReleaseGroup.keys}")
|
logD("Release groups for this artist: ${byReleaseGroup.keys}")
|
||||||
|
|
||||||
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
|
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
|
||||||
list.add(BasicHeader(entry.key.headerTitleRes))
|
val header = BasicHeader(entry.key.headerTitleRes)
|
||||||
|
list.add(Divider(header))
|
||||||
|
list.add(header)
|
||||||
list.addAll(entry.value)
|
list.addAll(entry.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -337,7 +455,9 @@ constructor(
|
||||||
var instructions: UpdateInstructions = UpdateInstructions.Diff
|
var instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||||
if (artist.songs.isNotEmpty()) {
|
if (artist.songs.isNotEmpty()) {
|
||||||
logD("Songs present in this artist, adding header")
|
logD("Songs present in this artist, adding header")
|
||||||
list.add(SortHeader(R.string.lbl_songs))
|
val header = SortHeader(R.string.lbl_songs)
|
||||||
|
list.add(Divider(header))
|
||||||
|
list.add(header)
|
||||||
if (replace) {
|
if (replace) {
|
||||||
// Intentional so that the header item isn't replaced with the songs
|
// Intentional so that the header item isn't replaced with the songs
|
||||||
instructions = UpdateInstructions.Replace(list.size)
|
instructions = UpdateInstructions.Replace(list.size)
|
||||||
|
|
@ -350,12 +470,17 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
|
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
|
||||||
logD("Refreshing genre data")
|
logD("Refreshing genre list")
|
||||||
val list = mutableListOf<Item>()
|
val list = mutableListOf<Item>()
|
||||||
// Genre is guaranteed to always have artists and songs.
|
// 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.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 =
|
val instructions =
|
||||||
if (replace) {
|
if (replace) {
|
||||||
// Intentional so that the header item isn't replaced with the songs
|
// Intentional so that the header item isn't replaced with the songs
|
||||||
|
|
@ -368,6 +493,25 @@ constructor(
|
||||||
_genreList.value = list
|
_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.
|
* 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.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.ConcatAdapter
|
import androidx.recyclerview.widget.ConcatAdapter
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
|
@ -34,18 +35,15 @@ import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
||||||
import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
|
import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
|
||||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
||||||
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
|
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.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
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.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,6 +59,7 @@ class GenreDetailFragment :
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
override val navModel: NavigationViewModel by activityViewModels()
|
override val navModel: NavigationViewModel by activityViewModels()
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
// Information about what genre to display is initially within the navigation arguments
|
// Information about what genre to display is initially within the navigation arguments
|
||||||
// as a UID, as that is the only safe way to parcel an genre.
|
// as a UID, as that is the only safe way to parcel an genre.
|
||||||
|
|
@ -85,18 +84,31 @@ class GenreDetailFragment :
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
binding.detailToolbar.apply {
|
binding.detailNormalToolbar.apply {
|
||||||
inflateMenu(R.menu.menu_genre_artist_detail)
|
inflateMenu(R.menu.menu_parent_detail)
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||||
setOnMenuItemClickListener(this@GenreDetailFragment)
|
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 ---
|
// --- VIEWMODEL SETUP ---
|
||||||
// DetailViewModel handles most initialization from the navigation argument.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setGenreUid(args.genreUid)
|
detailModel.setGenre(args.genreUid)
|
||||||
collectImmediately(detailModel.currentGenre, ::updateItem)
|
collectImmediately(detailModel.currentGenre, ::updatePlaylist)
|
||||||
collectImmediately(detailModel.genreList, ::updateList)
|
collectImmediately(detailModel.genreList, ::updateList)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
|
|
@ -106,7 +118,7 @@ class GenreDetailFragment :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.detailToolbar.setOnMenuItemClickListener(null)
|
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
binding.detailRecycler.adapter = null
|
binding.detailRecycler.adapter = null
|
||||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
// 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.
|
// 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)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(currentGenre)
|
||||||
|
true
|
||||||
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +170,7 @@ class GenreDetailFragment :
|
||||||
|
|
||||||
override fun onOpenMenu(item: Music, anchor: View) {
|
override fun onOpenMenu(item: Music, anchor: View) {
|
||||||
when (item) {
|
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)
|
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
|
||||||
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
||||||
}
|
}
|
||||||
|
|
@ -170,8 +186,10 @@ class GenreDetailFragment :
|
||||||
|
|
||||||
override fun onOpenSortMenu(anchor: View) {
|
override fun onOpenSortMenu(anchor: View) {
|
||||||
openMenu(anchor, R.menu.menu_genre_sort) {
|
openMenu(anchor, R.menu.menu_genre_sort) {
|
||||||
|
// Select the corresponding sort mode option
|
||||||
val sort = detailModel.genreSongSort
|
val sort = detailModel.genreSongSort
|
||||||
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
||||||
|
// Select the corresponding sort direction option
|
||||||
val directionItemId =
|
val directionItemId =
|
||||||
when (sort.direction) {
|
when (sort.direction) {
|
||||||
Sort.Direction.ASCENDING -> R.id.option_sort_asc
|
Sort.Direction.ASCENDING -> R.id.option_sort_asc
|
||||||
|
|
@ -182,8 +200,10 @@ class GenreDetailFragment :
|
||||||
item.isChecked = !item.isChecked
|
item.isChecked = !item.isChecked
|
||||||
detailModel.genreSongSort =
|
detailModel.genreSongSort =
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
|
// Sort direction options
|
||||||
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
|
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
|
||||||
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
|
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)))
|
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
|
|
@ -191,13 +211,13 @@ class GenreDetailFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateItem(genre: Genre?) {
|
private fun updatePlaylist(genre: Genre?) {
|
||||||
if (genre == null) {
|
if (genre == null) {
|
||||||
// Genre we were showing no longer exists.
|
// Genre we were showing no longer exists.
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requireBinding().detailToolbar.title = genre.resolveName(requireContext())
|
requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
|
||||||
genreHeaderAdapter.setParent(genre)
|
genreHeaderAdapter.setParent(genre)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,7 +253,7 @@ class GenreDetailFragment :
|
||||||
is Genre -> {
|
is Genre -> {
|
||||||
navModel.exploreNavigationItem.consume()
|
navModel.exploreNavigationItem.consume()
|
||||||
}
|
}
|
||||||
null -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,6 +263,13 @@ class GenreDetailFragment :
|
||||||
|
|
||||||
private fun updateSelection(selected: List<Music>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
genreListAdapter.setSelected(selected.toSet())
|
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.list.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.metadata.AudioInfo
|
import org.oxycblt.auxio.music.info.Name
|
||||||
|
import org.oxycblt.auxio.music.metadata.AudioProperties
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
|
|
@ -66,11 +67,11 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
binding.detailProperties.adapter = detailAdapter
|
binding.detailProperties.adapter = detailAdapter
|
||||||
// DetailViewModel handles most initialization from the navigation argument.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setSongUid(args.itemUid)
|
detailModel.setSong(args.songUid)
|
||||||
collectImmediately(detailModel.currentSong, detailModel.songAudioInfo, ::updateSong)
|
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSong(song: Song?, info: AudioInfo?) {
|
private fun updateSong(song: Song?, info: AudioProperties?) {
|
||||||
if (song == null) {
|
if (song == null) {
|
||||||
// Song we were showing no longer exists.
|
// Song we were showing no longer exists.
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
|
|
@ -123,12 +124,14 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T : Music> T.zipName(context: Context) =
|
private fun <T : Music> T.zipName(context: Context): String {
|
||||||
if (rawSortName != null) {
|
val name = name
|
||||||
getString(R.string.fmt_zipped_names, resolveName(context), rawSortName)
|
return if (name is Name.Known && name.sort != null) {
|
||||||
|
getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
|
||||||
} else {
|
} else {
|
||||||
resolveName(context)
|
name.resolve(context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T : Music> List<T>.zipNames(context: Context) =
|
private fun <T : Music> List<T>.zipNames(context: Context) =
|
||||||
concatLocalized(context) { it.zipName(context) }
|
concatLocalized(context) { it.zipName(context) }
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.util.inflater
|
||||||
/**
|
/**
|
||||||
* A [DetailHeaderAdapter] that shows [Album] information.
|
* A [DetailHeaderAdapter] that shows [Album] information.
|
||||||
*
|
*
|
||||||
|
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class AlbumDetailHeaderAdapter(private val listener: Listener) :
|
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.)
|
// The type text depends on the release type (Album, EP, Single, etc.)
|
||||||
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
|
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
|
// Artist name maps to the subhead text
|
||||||
binding.detailSubhead.apply {
|
binding.detailSubhead.apply {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import org.oxycblt.auxio.util.inflater
|
||||||
/**
|
/**
|
||||||
* A [DetailHeaderAdapter] that shows [Artist] information.
|
* A [DetailHeaderAdapter] that shows [Artist] information.
|
||||||
*
|
*
|
||||||
|
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class ArtistDetailHeaderAdapter(private val listener: Listener) :
|
class ArtistDetailHeaderAdapter(private val listener: Listener) :
|
||||||
|
|
@ -62,7 +63,18 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
|
||||||
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
|
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
|
||||||
binding.detailCover.bind(artist)
|
binding.detailCover.bind(artist)
|
||||||
binding.detailType.text = binding.context.getString(R.string.lbl_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()) {
|
if (artist.songs.isNotEmpty()) {
|
||||||
// Information about the artist's genre(s) map to the sub-head text
|
// 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)
|
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,
|
// 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.
|
// we want to reset the visibility of all information that was hidden.
|
||||||
binding.detailPlayButton.isVisible = true
|
binding.detailPlayButton.isVisible = true
|
||||||
|
|
@ -87,10 +92,8 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
|
||||||
// ex. Play and Shuffle, Song Counts, and Genre Information.
|
// ex. Play and Shuffle, Song Counts, and Genre Information.
|
||||||
// Artists are always guaranteed to have albums however, so continue to show those.
|
// Artists are always guaranteed to have albums however, so continue to show those.
|
||||||
binding.detailSubhead.isVisible = false
|
binding.detailSubhead.isVisible = false
|
||||||
binding.detailInfo.text =
|
binding.detailPlayButton.isEnabled = false
|
||||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
|
binding.detailShuffleButton.isEnabled = false
|
||||||
binding.detailPlayButton.isVisible = false
|
|
||||||
binding.detailShuffleButton.isVisible = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,17 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
|
||||||
*/
|
*/
|
||||||
fun setParent(parent: T) {
|
fun setParent(parent: T) {
|
||||||
currentParent = parent
|
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)
|
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An extended listener for [DetailHeaderAdapter] implementations. */
|
/** A listener for [DetailHeaderAdapter] implementations. */
|
||||||
interface Listener {
|
interface Listener {
|
||||||
/**
|
/**
|
||||||
* Called when the play button in a detail header is pressed, requesting that the current
|
* 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 androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
||||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
|
|
@ -33,6 +32,7 @@ import org.oxycblt.auxio.util.inflater
|
||||||
/**
|
/**
|
||||||
* A [DetailHeaderAdapter] that shows [Genre] information.
|
* A [DetailHeaderAdapter] that shows [Genre] information.
|
||||||
*
|
*
|
||||||
|
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class GenreDetailHeaderAdapter(private val listener: Listener) :
|
class GenreDetailHeaderAdapter(private val listener: Listener) :
|
||||||
|
|
@ -57,15 +57,15 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
|
||||||
* Bind new data to this instance.
|
* Bind new data to this instance.
|
||||||
*
|
*
|
||||||
* @param genre The new [Genre] to bind.
|
* @param genre The new [Genre] to bind.
|
||||||
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
|
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
|
fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
|
||||||
binding.detailCover.bind(genre)
|
binding.detailCover.bind(genre)
|
||||||
binding.detailType.text = binding.context.getString(R.string.lbl_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.
|
// Nothing about a genre is applicable to the sub-head text.
|
||||||
binding.detailSubhead.isVisible = false
|
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.detailInfo.text =
|
||||||
binding.context.getString(
|
binding.context.getString(
|
||||||
R.string.fmt_two,
|
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.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.metadata.Disc
|
import org.oxycblt.auxio.music.info.Disc
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
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 {
|
private companion object {
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
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
|
// Use duration instead of album or artist for each song, as this text would
|
||||||
// be homogenous otherwise.
|
// be homogenous otherwise.
|
||||||
|
|
@ -204,7 +195,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleDiffCallback<Song>() {
|
object : SimpleDiffCallback<Song>() {
|
||||||
override fun areContentsTheSame(oldItem: Song, newItem: 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 {
|
private companion object {
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
|
|
@ -106,7 +98,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
||||||
fun bind(album: Album, listener: SelectableListListener<Album>) {
|
fun bind(album: Album, listener: SelectableListListener<Album>) {
|
||||||
listener.bind(album, this, menuButton = binding.parentMenu)
|
listener.bind(album, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(album)
|
binding.parentImage.bind(album)
|
||||||
binding.parentName.text = album.resolveName(binding.context)
|
binding.parentName.text = album.name.resolve(binding.context)
|
||||||
binding.parentInfo.text =
|
binding.parentInfo.text =
|
||||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||||
album.dates?.resolveDate(binding.context)
|
album.dates?.resolveDate(binding.context)
|
||||||
|
|
@ -139,7 +131,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleDiffCallback<Album>() {
|
object : SimpleDiffCallback<Album>() {
|
||||||
override fun areContentsTheSame(oldItem: Album, newItem: 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>) {
|
fun bind(song: Song, listener: SelectableListListener<Song>) {
|
||||||
listener.bind(song, this, menuButton = binding.songMenu)
|
listener.bind(song, this, menuButton = binding.songMenu)
|
||||||
binding.songAlbumCover.bind(song)
|
binding.songAlbumCover.bind(song)
|
||||||
binding.songName.text = song.resolveName(binding.context)
|
binding.songName.text = song.name.resolve(binding.context)
|
||||||
binding.songInfo.text = song.album.resolveName(binding.context)
|
binding.songInfo.text = song.album.name.resolve(binding.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
|
|
@ -191,8 +183,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleDiffCallback<Song>() {
|
object : SimpleDiffCallback<Song>() {
|
||||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.name == newItem.name && oldItem.album.name == newItem.album.name
|
||||||
oldItem.album.rawName == newItem.album.rawName
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
|
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
|
||||||
import org.oxycblt.auxio.list.BasicHeader
|
import org.oxycblt.auxio.list.BasicHeader
|
||||||
|
import org.oxycblt.auxio.list.Divider
|
||||||
import org.oxycblt.auxio.list.Header
|
import org.oxycblt.auxio.list.Header
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
|
|
@ -47,13 +48,12 @@ import org.oxycblt.auxio.util.inflater
|
||||||
abstract class DetailListAdapter(
|
abstract class DetailListAdapter(
|
||||||
private val listener: Listener<*>,
|
private val listener: Listener<*>,
|
||||||
private val diffCallback: DiffUtil.ItemCallback<Item>
|
private val diffCallback: DiffUtil.ItemCallback<Item>
|
||||||
) :
|
) : SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback) {
|
||||||
SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback),
|
|
||||||
AuxioRecyclerView.SpanSizeLookup {
|
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) =
|
override fun getItemViewType(position: Int) =
|
||||||
when (getItem(position)) {
|
when (getItem(position)) {
|
||||||
// Implement support for headers and sort headers
|
// Implement support for headers and sort headers
|
||||||
|
is Divider -> DividerViewHolder.VIEW_TYPE
|
||||||
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
|
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
|
||||||
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
||||||
else -> super.getItemViewType(position)
|
else -> super.getItemViewType(position)
|
||||||
|
|
@ -61,6 +61,7 @@ abstract class DetailListAdapter(
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
when (viewType) {
|
when (viewType) {
|
||||||
|
DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent)
|
||||||
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
|
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
|
||||||
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
|
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
|
||||||
else -> error("Invalid item type $viewType")
|
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. */
|
/** An extended [SelectableListListener] for [DetailListAdapter] implementations. */
|
||||||
interface Listener<in T : Music> : SelectableListListener<T> {
|
interface Listener<in T : Music> : SelectableListListener<T> {
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,6 +89,8 @@ abstract class DetailListAdapter(
|
||||||
object : SimpleDiffCallback<Item>() {
|
object : SimpleDiffCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||||
return when {
|
return when {
|
||||||
|
oldItem is Divider && newItem is Divider ->
|
||||||
|
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||||
oldItem is BasicHeader && newItem is BasicHeader ->
|
oldItem is BasicHeader && newItem is BasicHeader ->
|
||||||
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||||
oldItem is SortHeader && newItem is SortHeader ->
|
oldItem is SortHeader && newItem is SortHeader ->
|
||||||
|
|
@ -114,8 +111,8 @@ abstract class DetailListAdapter(
|
||||||
data class SortHeader(@StringRes override val titleRes: Int) : Header
|
data class SortHeader(@StringRes override val titleRes: Int) : Header
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [BasicHeader] that adds
|
* A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create
|
||||||
* a button opening a menu for sorting. Use [from] to create an instance.
|
* an instance.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
|
|
@ -129,7 +126,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
|
||||||
*/
|
*/
|
||||||
fun bind(sortHeader: SortHeader, listener: DetailListAdapter.Listener<*>) {
|
fun bind(sortHeader: SortHeader, listener: DetailListAdapter.Listener<*>) {
|
||||||
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
|
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
|
||||||
binding.headerButton.apply {
|
binding.headerSort.apply {
|
||||||
// Add a Tooltip based on the content description so that the purpose of this
|
// Add a Tooltip based on the content description so that the purpose of this
|
||||||
// button can be clear.
|
// button can be clear.
|
||||||
TooltipCompat.setTooltipText(this, contentDescription)
|
TooltipCompat.setTooltipText(this, contentDescription)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.Song
|
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.
|
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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 {
|
private companion object {
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleDiffCallback<Item>() {
|
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.MainFragmentDirections
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
||||||
import org.oxycblt.auxio.home.list.AlbumListFragment
|
import org.oxycblt.auxio.home.list.*
|
||||||
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.tabs.AdaptiveTabStrategy
|
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.model.Library
|
import org.oxycblt.auxio.navigation.MainNavigationAction
|
||||||
import org.oxycblt.auxio.music.system.Indexer
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -73,8 +68,8 @@ class HomeFragment :
|
||||||
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
|
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
private val musicModel: MusicViewModel by activityViewModels()
|
|
||||||
private val navModel: NavigationViewModel by activityViewModels()
|
private val navModel: NavigationViewModel by activityViewModels()
|
||||||
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
|
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
|
||||||
|
|
||||||
|
|
@ -107,7 +102,7 @@ class HomeFragment :
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
binding.homeAppbar.addOnOffsetChangedListener(this)
|
binding.homeAppbar.addOnOffsetChangedListener(this)
|
||||||
binding.homeToolbar.apply {
|
binding.homeNormalToolbar.apply {
|
||||||
setOnMenuItemClickListener(this@HomeFragment)
|
setOnMenuItemClickListener(this@HomeFragment)
|
||||||
MenuCompat.setGroupDividerEnabled(menu, true)
|
MenuCompat.setGroupDividerEnabled(menu, true)
|
||||||
}
|
}
|
||||||
|
|
@ -152,13 +147,11 @@ class HomeFragment :
|
||||||
// re-creating the ViewPager.
|
// re-creating the ViewPager.
|
||||||
setupPager(binding)
|
setupPager(binding)
|
||||||
|
|
||||||
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
|
|
||||||
|
|
||||||
// --- VIEWMODEL SETUP ---
|
// --- VIEWMODEL SETUP ---
|
||||||
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
||||||
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
|
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
|
||||||
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
|
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
|
||||||
collectImmediately(musicModel.indexerState, ::updateIndexerState)
|
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
||||||
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
|
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
|
||||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +169,7 @@ class HomeFragment :
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
storagePermissionLauncher = null
|
storagePermissionLauncher = null
|
||||||
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
||||||
binding.homeToolbar.setOnMenuItemClickListener(null)
|
binding.homeNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
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,
|
// 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
|
// the alpha transition is shifted such that the Toolbar becomes fully transparent
|
||||||
// when the AppBarLayout is only at half-collapsed.
|
// when the AppBarLayout is only at half-collapsed.
|
||||||
binding.homeSelectionToolbar.alpha =
|
binding.homeToolbar.alpha = 1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
|
||||||
1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
|
|
||||||
binding.homeContent.updatePadding(
|
binding.homeContent.updatePadding(
|
||||||
bottom = binding.homeAppbar.totalScrollRange + verticalOffset)
|
bottom = binding.homeAppbar.totalScrollRange + verticalOffset)
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +242,7 @@ class HomeFragment :
|
||||||
binding.homePager.adapter =
|
binding.homePager.adapter =
|
||||||
HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner)
|
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) {
|
if (homeModel.currentTabModes.size == 1) {
|
||||||
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
||||||
// behavior.
|
// behavior.
|
||||||
|
|
@ -273,6 +265,7 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateCurrentTab(tabMode: MusicMode) {
|
private fun updateCurrentTab(tabMode: MusicMode) {
|
||||||
|
val binding = requireBinding()
|
||||||
// Update the sort options to align with those allowed by the tab
|
// Update the sort options to align with those allowed by the tab
|
||||||
val isVisible: (Int) -> Boolean =
|
val isVisible: (Int) -> Boolean =
|
||||||
when (tabMode) {
|
when (tabMode) {
|
||||||
|
|
@ -280,16 +273,8 @@ class HomeFragment :
|
||||||
MusicMode.SONGS -> { id -> id != R.id.option_sort_count }
|
MusicMode.SONGS -> { id -> id != R.id.option_sort_count }
|
||||||
// Disallow sorting by album for albums
|
// Disallow sorting by album for albums
|
||||||
MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album }
|
MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album }
|
||||||
// Only allow sorting by name, count, and duration for artists
|
// Only allow sorting by name, count, and duration for parents
|
||||||
MusicMode.ARTISTS -> { id ->
|
else -> { 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 ->
|
|
||||||
id == R.id.option_sort_asc ||
|
id == R.id.option_sort_asc ||
|
||||||
id == R.id.option_sort_dec ||
|
id == R.id.option_sort_dec ||
|
||||||
id == R.id.option_sort_name ||
|
id == R.id.option_sort_name ||
|
||||||
|
|
@ -299,8 +284,7 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
val sortMenu =
|
val sortMenu =
|
||||||
unlikelyToBeNull(
|
unlikelyToBeNull(binding.homeNormalToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
|
||||||
requireBinding().homeToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
|
|
||||||
val toHighlight = homeModel.getSortForTab(tabMode)
|
val toHighlight = homeModel.getSortForTab(tabMode)
|
||||||
|
|
||||||
for (option in sortMenu) {
|
for (option in sortMenu) {
|
||||||
|
|
@ -321,13 +305,24 @@ class HomeFragment :
|
||||||
// Update the scrolling view in AppBarLayout to align with the current tab's
|
// 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
|
// scrolling state. This prevents the lift state from being confused as one
|
||||||
// goes between different tabs.
|
// goes between different tabs.
|
||||||
requireBinding().homeAppbar.liftOnScrollTargetViewId =
|
binding.homeAppbar.liftOnScrollTargetViewId =
|
||||||
when (tabMode) {
|
when (tabMode) {
|
||||||
MusicMode.SONGS -> R.id.home_song_recycler
|
MusicMode.SONGS -> R.id.home_song_recycler
|
||||||
MusicMode.ALBUMS -> R.id.home_album_recycler
|
MusicMode.ALBUMS -> R.id.home_album_recycler
|
||||||
MusicMode.ARTISTS -> R.id.home_artist_recycler
|
MusicMode.ARTISTS -> R.id.home_artist_recycler
|
||||||
MusicMode.GENRES -> R.id.home_genre_recycler
|
MusicMode.GENRES -> R.id.home_genre_recycler
|
||||||
|
MusicMode.PLAYLISTS -> R.id.home_playlist_recycler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tabMode != MusicMode.PLAYLISTS) {
|
||||||
|
binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
|
||||||
|
playbackModel.shuffleAll()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
|
||||||
|
musicModel.createPlaylist()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleRecreate(recreate: Unit?) {
|
private fun handleRecreate(recreate: Unit?) {
|
||||||
|
|
@ -340,14 +335,14 @@ class HomeFragment :
|
||||||
homeModel.recreateTabs.consume()
|
homeModel.recreateTabs.consume()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateIndexerState(state: Indexer.State?) {
|
private fun updateIndexerState(state: IndexingState?) {
|
||||||
// TODO: Make music loading experience a bit more pleasant
|
// TODO: Make music loading experience a bit more pleasant
|
||||||
// 1. Loading placeholder for item lists
|
// 1. Loading placeholder for item lists
|
||||||
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
|
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
when (state) {
|
when (state) {
|
||||||
is Indexer.State.Complete -> setupCompleteState(binding, state.result)
|
is IndexingState.Completed -> setupCompleteState(binding, state.error)
|
||||||
is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
|
is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
|
||||||
null -> {
|
null -> {
|
||||||
logD("Indexer is in indeterminate state")
|
logD("Indexer is in indeterminate state")
|
||||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||||
|
|
@ -355,77 +350,77 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupCompleteState(binding: FragmentHomeBinding, result: Result<Library>) {
|
private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) {
|
||||||
if (result.isSuccess) {
|
if (error == null) {
|
||||||
logD("Received ok response")
|
logD("Received ok response")
|
||||||
binding.homeFab.show()
|
binding.homeFab.show()
|
||||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||||
} else {
|
return
|
||||||
logD("Received non-ok response")
|
}
|
||||||
val context = requireContext()
|
|
||||||
val throwable = unlikelyToBeNull(result.exceptionOrNull())
|
logD("Received non-ok response")
|
||||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
val context = requireContext()
|
||||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||||
when (throwable) {
|
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||||
is Indexer.NoPermissionException -> {
|
when (error) {
|
||||||
logD("Updating UI to permission request state")
|
is NoAudioPermissionException -> {
|
||||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
logD("Updating UI to permission request state")
|
||||||
// Configure the action to act as a permission launcher.
|
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
||||||
binding.homeIndexingAction.apply {
|
// Configure the action to act as a permission launcher.
|
||||||
visibility = View.VISIBLE
|
binding.homeIndexingAction.apply {
|
||||||
text = context.getString(R.string.lbl_grant)
|
visibility = View.VISIBLE
|
||||||
setOnClickListener {
|
text = context.getString(R.string.lbl_grant)
|
||||||
requireNotNull(storagePermissionLauncher) {
|
setOnClickListener {
|
||||||
"Permission launcher was not available"
|
requireNotNull(storagePermissionLauncher) {
|
||||||
}
|
"Permission launcher was not available"
|
||||||
.launch(Indexer.PERMISSION_READ_AUDIO)
|
}
|
||||||
}
|
.launch(PERMISSION_READ_AUDIO)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Indexer.NoMusicException -> {
|
}
|
||||||
logD("Updating UI to no music state")
|
is NoMusicException -> {
|
||||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
logD("Updating UI to no music state")
|
||||||
// Configure the action to act as a reload trigger.
|
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
||||||
binding.homeIndexingAction.apply {
|
// Configure the action to act as a reload trigger.
|
||||||
visibility = View.VISIBLE
|
binding.homeIndexingAction.apply {
|
||||||
text = context.getString(R.string.lbl_retry)
|
visibility = View.VISIBLE
|
||||||
setOnClickListener { musicModel.refresh() }
|
text = context.getString(R.string.lbl_retry)
|
||||||
}
|
setOnClickListener { musicModel.refresh() }
|
||||||
}
|
}
|
||||||
else -> {
|
}
|
||||||
logD("Updating UI to error state")
|
else -> {
|
||||||
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
logD("Updating UI to error state")
|
||||||
// Configure the action to act as a reload trigger.
|
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
||||||
binding.homeIndexingAction.apply {
|
// Configure the action to act as a reload trigger.
|
||||||
visibility = View.VISIBLE
|
binding.homeIndexingAction.apply {
|
||||||
text = context.getString(R.string.lbl_retry)
|
visibility = View.VISIBLE
|
||||||
setOnClickListener { musicModel.rescan() }
|
text = context.getString(R.string.lbl_retry)
|
||||||
}
|
setOnClickListener { musicModel.rescan() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) {
|
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
|
||||||
// Remove all content except for the progress indicator.
|
// Remove all content except for the progress indicator.
|
||||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||||
binding.homeIndexingProgress.visibility = View.VISIBLE
|
binding.homeIndexingProgress.visibility = View.VISIBLE
|
||||||
binding.homeIndexingAction.visibility = View.INVISIBLE
|
binding.homeIndexingAction.visibility = View.INVISIBLE
|
||||||
|
|
||||||
when (indexing) {
|
when (progress) {
|
||||||
is Indexer.Indexing.Indeterminate -> {
|
is IndexingProgress.Indeterminate -> {
|
||||||
// In a query/initialization state, show a generic loading status.
|
// In a query/initialization state, show a generic loading status.
|
||||||
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
|
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
|
||||||
binding.homeIndexingProgress.isIndeterminate = true
|
binding.homeIndexingProgress.isIndeterminate = true
|
||||||
}
|
}
|
||||||
is Indexer.Indexing.Songs -> {
|
is IndexingProgress.Songs -> {
|
||||||
// Actively loading songs, show the current progress.
|
// Actively loading songs, show the current progress.
|
||||||
binding.homeIndexingStatus.text =
|
binding.homeIndexingStatus.text =
|
||||||
getString(R.string.fmt_indexing, indexing.current, indexing.total)
|
getString(R.string.fmt_indexing, progress.current, progress.total)
|
||||||
binding.homeIndexingProgress.apply {
|
binding.homeIndexingProgress.apply {
|
||||||
isIndeterminate = false
|
isIndeterminate = false
|
||||||
max = indexing.total
|
max = progress.total
|
||||||
progress = indexing.current
|
this.progress = progress.current
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -450,7 +445,8 @@ class HomeFragment :
|
||||||
is Album -> HomeFragmentDirections.actionShowAlbum(item.uid)
|
is Album -> HomeFragmentDirections.actionShowAlbum(item.uid)
|
||||||
is Artist -> HomeFragmentDirections.actionShowArtist(item.uid)
|
is Artist -> HomeFragmentDirections.actionShowArtist(item.uid)
|
||||||
is Genre -> HomeFragmentDirections.actionShowGenre(item.uid)
|
is Genre -> HomeFragmentDirections.actionShowGenre(item.uid)
|
||||||
else -> return
|
is Playlist -> HomeFragmentDirections.actionShowPlaylist(item.uid)
|
||||||
|
null -> return
|
||||||
}
|
}
|
||||||
|
|
||||||
setupAxisTransitions(MaterialSharedAxis.X)
|
setupAxisTransitions(MaterialSharedAxis.X)
|
||||||
|
|
@ -459,11 +455,15 @@ class HomeFragment :
|
||||||
|
|
||||||
private fun updateSelection(selected: List<Music>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
|
if (selected.isNotEmpty()) {
|
||||||
selected.isNotEmpty()) {
|
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
|
||||||
// New selection started, show the AppBarLayout to indicate the new state.
|
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
|
||||||
logD("Significant selection occurred, expanding AppBar")
|
// New selection started, show the AppBarLayout to indicate the new state.
|
||||||
binding.homeAppbar.expandWithScrollingRecycler()
|
logD("Significant selection occurred, expanding AppBar")
|
||||||
|
binding.homeAppbar.expandWithScrollingRecycler()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.homeToolbar.setVisible(R.id.home_normal_toolbar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -499,6 +499,7 @@ class HomeFragment :
|
||||||
MusicMode.ALBUMS -> AlbumListFragment()
|
MusicMode.ALBUMS -> AlbumListFragment()
|
||||||
MusicMode.ARTISTS -> ArtistListFragment()
|
MusicMode.ARTISTS -> ArtistListFragment()
|
||||||
MusicMode.GENRES -> GenreListFragment()
|
MusicMode.GENRES -> GenreListFragment()
|
||||||
|
MusicMode.PLAYLISTS -> PlaylistListFragment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.home.tabs.Tab
|
import org.oxycblt.auxio.home.tabs.Tab
|
||||||
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
|
@ -64,10 +65,32 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
||||||
override val shouldHideCollaborators: Boolean
|
override val shouldHideCollaborators: Boolean
|
||||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false)
|
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) {
|
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
|
||||||
when (key) {
|
when (key) {
|
||||||
getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
|
getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
|
||||||
getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged()
|
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.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.model.Library
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.Event
|
import org.oxycblt.auxio.util.Event
|
||||||
import org.oxycblt.auxio.util.MutableEvent
|
import org.oxycblt.auxio.util.MutableEvent
|
||||||
|
|
@ -46,7 +45,7 @@ constructor(
|
||||||
private val playbackSettings: PlaybackSettings,
|
private val playbackSettings: PlaybackSettings,
|
||||||
private val musicRepository: MusicRepository,
|
private val musicRepository: MusicRepository,
|
||||||
private val musicSettings: MusicSettings
|
private val musicSettings: MusicSettings
|
||||||
) : ViewModel(), MusicRepository.Listener, HomeSettings.Listener {
|
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
|
||||||
|
|
||||||
private val _songsList = MutableStateFlow(listOf<Song>())
|
private val _songsList = MutableStateFlow(listOf<Song>())
|
||||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||||
|
|
@ -88,6 +87,15 @@ constructor(
|
||||||
val genresInstructions: Event<UpdateInstructions>
|
val genresInstructions: Event<UpdateInstructions>
|
||||||
get() = _genresInstructions
|
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. */
|
/** The [MusicMode] to use when playing a [Song] from the UI. */
|
||||||
val playbackMode: MusicMode
|
val playbackMode: MusicMode
|
||||||
get() = playbackSettings.inListPlaybackMode
|
get() = playbackSettings.inListPlaybackMode
|
||||||
|
|
@ -117,37 +125,45 @@ constructor(
|
||||||
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
|
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
homeSettings.registerListener(this)
|
homeSettings.registerListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
musicRepository.removeListener(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
homeSettings.unregisterListener(this)
|
homeSettings.unregisterListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: Library?) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (library != null) {
|
val deviceLibrary = musicRepository.deviceLibrary
|
||||||
logD("Library changed, refreshing library")
|
logD(changes.deviceLibrary)
|
||||||
// FIXME: Sort name setting changes result in incorrect list updates
|
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||||
|
logD("Refreshing library")
|
||||||
// Get the each list of items in the library to use as our list data.
|
// Get the each list of items in the library to use as our list data.
|
||||||
// Applying the preferred sorting to them.
|
// Applying the preferred sorting to them.
|
||||||
_songsInstructions.put(UpdateInstructions.Diff)
|
_songsInstructions.put(UpdateInstructions.Diff)
|
||||||
_songsList.value = musicSettings.songSort.songs(library.songs)
|
_songsList.value = musicSettings.songSort.songs(deviceLibrary.songs)
|
||||||
_albumsInstructions.put(UpdateInstructions.Diff)
|
_albumsInstructions.put(UpdateInstructions.Diff)
|
||||||
_albumsLists.value = musicSettings.albumSort.albums(library.albums)
|
_albumsLists.value = musicSettings.albumSort.albums(deviceLibrary.albums)
|
||||||
_artistsInstructions.put(UpdateInstructions.Diff)
|
_artistsInstructions.put(UpdateInstructions.Diff)
|
||||||
_artistsList.value =
|
_artistsList.value =
|
||||||
musicSettings.artistSort.artists(
|
musicSettings.artistSort.artists(
|
||||||
if (homeSettings.shouldHideCollaborators) {
|
if (homeSettings.shouldHideCollaborators) {
|
||||||
// Hide Collaborators is enabled, filter out collaborators.
|
// Hide Collaborators is enabled, filter out collaborators.
|
||||||
library.artists.filter { !it.isCollaborator }
|
deviceLibrary.artists.filter { !it.isCollaborator }
|
||||||
} else {
|
} else {
|
||||||
library.artists
|
deviceLibrary.artists
|
||||||
})
|
})
|
||||||
_genresInstructions.put(UpdateInstructions.Diff)
|
_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() {
|
override fun onHideCollaboratorsChanged() {
|
||||||
// Changes in the hide collaborator setting will change the artist contents
|
// Changes in the hide collaborator setting will change the artist contents
|
||||||
// of the library, consider it a library update.
|
// 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.ALBUMS -> musicSettings.albumSort
|
||||||
MusicMode.ARTISTS -> musicSettings.artistSort
|
MusicMode.ARTISTS -> musicSettings.artistSort
|
||||||
MusicMode.GENRES -> musicSettings.genreSort
|
MusicMode.GENRES -> musicSettings.genreSort
|
||||||
|
MusicMode.PLAYLISTS -> musicSettings.playlistSort
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -206,6 +223,11 @@ constructor(
|
||||||
_genresInstructions.put(UpdateInstructions.Replace(0))
|
_genresInstructions.put(UpdateInstructions.Replace(0))
|
||||||
_genresList.value = sort.genres(_genresList.value)
|
_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.recycler.AlbumViewHolder
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.playback.secsToMs
|
import org.oxycblt.auxio.playback.secsToMs
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -56,6 +56,7 @@ class AlbumListFragment :
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
override val navModel: NavigationViewModel by activityViewModels()
|
override val navModel: NavigationViewModel by activityViewModels()
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
private val albumAdapter = AlbumAdapter(this)
|
private val albumAdapter = AlbumAdapter(this)
|
||||||
// Save memory by re-using the same formatter and string builder when creating popup text
|
// 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.
|
// Change how we display the popup depending on the current sort mode.
|
||||||
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
|
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
|
||||||
// By Name -> Use Name
|
// By Name -> Use Name
|
||||||
is Sort.Mode.ByName -> album.sortName?.thumbString
|
is Sort.Mode.ByName -> album.name.thumb
|
||||||
|
|
||||||
// By Artist -> Use name of first artist
|
// 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)
|
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
|
||||||
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
|
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,10 @@ import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
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.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
|
@ -58,6 +59,7 @@ class ArtistListFragment :
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
override val navModel: NavigationViewModel by activityViewModels()
|
override val navModel: NavigationViewModel by activityViewModels()
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
private val artistAdapter = ArtistAdapter(this)
|
private val artistAdapter = ArtistAdapter(this)
|
||||||
|
|
||||||
|
|
@ -93,7 +95,7 @@ class ArtistListFragment :
|
||||||
// Change how we display the popup depending on the current sort mode.
|
// Change how we display the popup depending on the current sort mode.
|
||||||
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
|
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
|
||||||
// By Name -> Use Name
|
// By Name -> Use Name
|
||||||
is Sort.Mode.ByName -> artist.sortName?.thumbString
|
is Sort.Mode.ByName -> artist.name.thumb
|
||||||
|
|
||||||
// Duration -> Use formatted duration
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
|
||||||
|
|
@ -115,7 +117,7 @@ class ArtistListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenMenu(item: Artist, anchor: View) {
|
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>) {
|
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.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
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.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
|
|
@ -57,6 +58,7 @@ class GenreListFragment :
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
override val navModel: NavigationViewModel by activityViewModels()
|
override val navModel: NavigationViewModel by activityViewModels()
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
private val genreAdapter = GenreAdapter(this)
|
private val genreAdapter = GenreAdapter(this)
|
||||||
|
|
||||||
|
|
@ -92,7 +94,7 @@ class GenreListFragment :
|
||||||
// Change how we display the popup depending on the current sort mode.
|
// Change how we display the popup depending on the current sort mode.
|
||||||
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
|
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
|
||||||
// By Name -> Use Name
|
// By Name -> Use Name
|
||||||
is Sort.Mode.ByName -> genre.sortName?.thumbString
|
is Sort.Mode.ByName -> genre.name.thumb
|
||||||
|
|
||||||
// Duration -> Use formatted duration
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
|
||||||
|
|
@ -114,7 +116,7 @@ class GenreListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenMenu(item: Genre, anchor: View) {
|
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>) {
|
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.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.playback.secsToMs
|
import org.oxycblt.auxio.playback.secsToMs
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,6 +60,7 @@ class SongListFragment :
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
override val navModel: NavigationViewModel by activityViewModels()
|
override val navModel: NavigationViewModel by activityViewModels()
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
private val songAdapter = SongAdapter(this)
|
private val songAdapter = SongAdapter(this)
|
||||||
// Save memory by re-using the same formatter and string builder when creating popup text
|
// 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.
|
// based off the names of the parent objects and not the child objects.
|
||||||
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
|
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
|
||||||
// Name -> Use name
|
// Name -> Use name
|
||||||
is Sort.Mode.ByName -> song.sortName?.thumbString
|
is Sort.Mode.ByName -> song.name.thumb
|
||||||
|
|
||||||
// Artist -> Use name of first artist
|
// 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
|
// Album -> Use Album Name
|
||||||
is Sort.Mode.ByAlbum -> song.album.sortName?.thumbString
|
is Sort.Mode.ByAlbum -> song.album.name.thumb
|
||||||
|
|
||||||
// Year -> Use Full Year
|
// Year -> Use Full Year
|
||||||
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
|
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicMode>) :
|
||||||
icon = R.drawable.ic_genre_24
|
icon = R.drawable.ic_genre_24
|
||||||
string = R.string.lbl_genres
|
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.
|
// 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
|
// 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:
|
// Each chunk in a sequence is represented as:
|
||||||
//
|
//
|
||||||
// VTTT
|
// 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
|
// Where V is a bit representing the visibility and T is a 3-bit integer representing the
|
||||||
// MusicMode for this tab.
|
// MusicMode for this tab.
|
||||||
|
|
||||||
/** The length a well-formed tab sequence should be. */
|
/** The maximum index that a well-formed tab sequence should be. */
|
||||||
private const val SEQUENCE_LEN = 4
|
private const val MAX_SEQUENCE_IDX = 4
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default tab sequence, in integer form. This represents a set of four visible tabs
|
* 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]. */
|
/** Maps between the integer code in the tab sequence and it's [MusicMode]. */
|
||||||
private val MODE_TABLE =
|
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.
|
* 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.
|
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
|
||||||
val distinct = tabs.distinctBy { it.mode }
|
val distinct = tabs.distinctBy { it.mode }
|
||||||
|
|
||||||
var sequence = 0b0100
|
var sequence = 0
|
||||||
var shift = SEQUENCE_LEN * 4
|
var shift = MAX_SEQUENCE_IDX * 4
|
||||||
for (tab in distinct) {
|
for (tab in distinct) {
|
||||||
val bin =
|
val bin =
|
||||||
when (tab) {
|
when (tab) {
|
||||||
|
|
@ -107,9 +112,8 @@ sealed class Tab(open val mode: MusicMode) {
|
||||||
|
|
||||||
// Try to parse a mode for each chunk in the sequence.
|
// Try to parse a mode for each chunk in the sequence.
|
||||||
// If we can't parse one, just skip it.
|
// 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 chunk = intCode.shr(shift) and 0b1111
|
||||||
|
|
||||||
val mode = MODE_TABLE.getOrNull(chunk and 7) ?: continue
|
val mode = MODE_TABLE.getOrNull(chunk and 7) ?: continue
|
||||||
|
|
||||||
// Figure out the visibility
|
// Figure out the visibility
|
||||||
|
|
@ -125,7 +129,7 @@ sealed class Tab(open val mode: MusicMode) {
|
||||||
val distinct = tabs.distinctBy { it.mode }
|
val distinct = tabs.distinctBy { it.mode }
|
||||||
|
|
||||||
// For safety, return null if we have an empty or larger-than-expected tab array.
|
// 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")
|
logE("Sequence size was ${distinct.size}, which is invalid")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemTabBinding
|
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.list.recycler.DialogRecyclerView
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.util.inflater
|
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.
|
* 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>() {
|
RecyclerView.Adapter<TabViewHolder>() {
|
||||||
/** The current array of [Tab]s. */
|
/** The current array of [Tab]s. */
|
||||||
var tabs = arrayOf<Tab>()
|
var tabs = arrayOf<Tab>()
|
||||||
|
|
@ -97,10 +97,10 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
||||||
* Bind new data to this instance.
|
* Bind new data to this instance.
|
||||||
*
|
*
|
||||||
* @param tab The new [Tab] to bind.
|
* @param tab The new [Tab] to bind.
|
||||||
* @param listener A [EditableListListener] to bind interactions to.
|
* @param listener A [EditClickListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
fun bind(tab: Tab, listener: EditableListListener<Tab>) {
|
fun bind(tab: Tab, listener: EditClickListListener<Tab>) {
|
||||||
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
|
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
|
||||||
binding.tabCheckBox.apply {
|
binding.tabCheckBox.apply {
|
||||||
// Update the CheckBox name to align with the mode
|
// 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.ALBUMS -> R.string.lbl_albums
|
||||||
MusicMode.ARTISTS -> R.string.lbl_artists
|
MusicMode.ARTISTS -> R.string.lbl_artists
|
||||||
MusicMode.GENRES -> R.string.lbl_genres
|
MusicMode.GENRES -> R.string.lbl_genres
|
||||||
|
MusicMode.PLAYLISTS -> R.string.lbl_playlists
|
||||||
})
|
})
|
||||||
|
|
||||||
// Unlike in other adapters, we update the checked state alongside
|
// 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.R
|
||||||
import org.oxycblt.auxio.databinding.DialogTabsBinding
|
import org.oxycblt.auxio.databinding.DialogTabsBinding
|
||||||
import org.oxycblt.auxio.home.HomeSettings
|
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.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.logD
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class TabCustomizeDialog :
|
class TabCustomizeDialog :
|
||||||
ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener<Tab> {
|
ViewBindingDialogFragment<DialogTabsBinding>(), EditClickListListener<Tab> {
|
||||||
private val tabAdapter = TabAdapter(this)
|
private val tabAdapter = TabAdapter(this)
|
||||||
private var touchHelper: ItemTouchHelper? = null
|
private var touchHelper: ItemTouchHelper? = null
|
||||||
@Inject lateinit var homeSettings: HomeSettings
|
@Inject lateinit var homeSettings: HomeSettings
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ constructor(
|
||||||
target
|
target
|
||||||
.onConfigRequest(
|
.onConfigRequest(
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(song)
|
.data(listOf(song))
|
||||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||||
.size(Size.ORIGINAL)
|
.size(Size.ORIGINAL)
|
||||||
.transformations(SquareFrameTransform.INSTANCE))
|
.transformations(SquareFrameTransform.INSTANCE))
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,7 @@ import androidx.annotation.AttrRes
|
||||||
import androidx.core.view.updateMarginsRelative
|
import androidx.core.view.updateMarginsRelative
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||||
import org.oxycblt.auxio.util.getColorCompat
|
import org.oxycblt.auxio.util.getColorCompat
|
||||||
import org.oxycblt.auxio.util.getDimenPixels
|
import org.oxycblt.auxio.util.getDimenPixels
|
||||||
|
|
@ -52,6 +49,9 @@ import org.oxycblt.auxio.util.getInteger
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*
|
*
|
||||||
* TODO: Rework content descriptions here
|
* 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
|
class ImageGroup
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
|
|
@ -177,6 +177,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
*/
|
*/
|
||||||
fun bind(genre: Genre) = innerImageView.bind(genre)
|
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
|
* 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
|
* PlaybackIndicatorView for more information on what occurs here. Note: It's expected for this
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,10 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.image
|
package org.oxycblt.auxio.image
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import coil.ImageLoader
|
|
||||||
import coil.request.CachePolicy
|
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
|
||||||
import org.oxycblt.auxio.image.extractor.*
|
import org.oxycblt.auxio.image.extractor.*
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|
@ -35,31 +29,3 @@ import org.oxycblt.auxio.image.extractor.*
|
||||||
interface ImageModule {
|
interface ImageModule {
|
||||||
@Binds fun settings(imageSettings: ImageSettingsImpl): ImageSettings
|
@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 javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
|
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
import org.oxycblt.auxio.util.getColorCompat
|
import org.oxycblt.auxio.util.getColorCompat
|
||||||
import org.oxycblt.auxio.util.getDrawableCompat
|
import org.oxycblt.auxio.util.getDrawableCompat
|
||||||
|
|
@ -100,41 +96,54 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
*
|
*
|
||||||
* @param song The [Song] to bind.
|
* @param song The [Song] to bind.
|
||||||
*/
|
*/
|
||||||
fun bind(song: Song) = bindImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover)
|
fun bind(song: Song) = bind(song.album)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bind an [Album]'s cover to this view, also updating the content description.
|
* Bind an [Album]'s cover to this view, also updating the content description.
|
||||||
*
|
*
|
||||||
* @param album the [Album] to bind.
|
* @param album the [Album] to bind.
|
||||||
*/
|
*/
|
||||||
fun bind(album: Album) = bindImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover)
|
fun bind(album: Album) = 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.
|
* Bind an [Artist]'s image to this view, also updating the content description.
|
||||||
*
|
*
|
||||||
* @param artist the [Artist] to bind.
|
* @param artist the [Artist] to bind.
|
||||||
*/
|
*/
|
||||||
fun bind(artist: Artist) = bindImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
|
fun bind(artist: Artist) = 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.
|
* Bind an [Genre]'s image to this view, also updating the content description.
|
||||||
*
|
*
|
||||||
* @param genre the [Genre] to bind.
|
* @param genre the [Genre] to bind.
|
||||||
*/
|
*/
|
||||||
fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
|
fun bind(genre: Genre) = 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 playlist The [Playlist] to bind.
|
||||||
* @param errorRes The error drawable resource to use if the music cannot be loaded.
|
* @param songs [Song]s that can override the playlist image if it needs to differ for any
|
||||||
* @param descRes The content description string resource to use. The resource must have one
|
* reason.
|
||||||
* field for the name of the [Music].
|
|
||||||
*/
|
*/
|
||||||
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 =
|
val request =
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(music)
|
.data(songs)
|
||||||
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
|
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
|
||||||
.transformations(SquareFrameTransform.INSTANCE)
|
.transformations(SquareFrameTransform.INSTANCE)
|
||||||
.target(this)
|
.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.
|
// Dispose of any previous image request and load a new image.
|
||||||
CoilUtils.dispose(this)
|
CoilUtils.dispose(this)
|
||||||
imageLoader.enqueue(request)
|
imageLoader.enqueue(request)
|
||||||
// Update the content description to the specified resource.
|
contentDescription = desc
|
||||||
contentDescription = context.getString(descRes, music.resolveName(context))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -18,153 +18,31 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.extractor
|
package org.oxycblt.auxio.image.extractor
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.decode.DataSource
|
|
||||||
import coil.decode.ImageSource
|
|
||||||
import coil.fetch.FetchResult
|
|
||||||
import coil.fetch.Fetcher
|
import coil.fetch.Fetcher
|
||||||
import coil.fetch.SourceResult
|
|
||||||
import coil.key.Keyer
|
import coil.key.Keyer
|
||||||
import coil.request.Options
|
import coil.request.Options
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.min
|
import org.oxycblt.auxio.music.*
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||||
* A [Keyer] implementation for [Music] data.
|
Keyer<List<Song>> {
|
||||||
*
|
override fun key(data: List<Song>, options: Options) =
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
"${coverExtractor.computeAlbumOrdering(data).hashCode()}"
|
||||||
*/
|
|
||||||
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 SongCoverFetcher
|
||||||
* Generic [Fetcher] for [Album] covers. Works with both [Album] and [Song]. Use [SongFactory] or
|
|
||||||
* [AlbumFactory] for instantiation.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class AlbumCoverFetcher
|
|
||||||
private constructor(
|
private constructor(
|
||||||
private val context: Context,
|
private val songs: List<Song>,
|
||||||
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 size: Size,
|
private val size: Size,
|
||||||
private val artist: Artist
|
private val coverExtractor: CoverExtractor,
|
||||||
) : Fetcher {
|
) : Fetcher {
|
||||||
override suspend fun fetch(): FetchResult? {
|
override suspend fun fetch() = coverExtractor.extract(songs, size)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [Fetcher.Factory] implementation. */
|
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||||
class Factory @Inject constructor(private val extractor: CoverExtractor) :
|
Fetcher.Factory<List<Song>> {
|
||||||
Fetcher.Factory<Artist> {
|
override fun create(data: List<Song>, options: Options, imageLoader: ImageLoader) =
|
||||||
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
|
SongCoverFetcher(data, options.size, coverExtractor)
|
||||||
ArtistImageFetcher(options.context, extractor, options.size, data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* [Fetcher] for [Genre] images. Use [Factory] for instantiation.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class GenreImageFetcher
|
|
||||||
private constructor(
|
|
||||||
private val context: Context,
|
|
||||||
private val 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
|
package org.oxycblt.auxio.image.extractor
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import android.util.Size as AndroidSize
|
||||||
import com.google.android.exoplayer2.MediaMetadata
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import com.google.android.exoplayer2.MetadataRetriever
|
import androidx.media3.common.MediaItem
|
||||||
import com.google.android.exoplayer2.metadata.flac.PictureFrame
|
import androidx.media3.common.MediaMetadata
|
||||||
import com.google.android.exoplayer2.metadata.id3.ApicFrame
|
import androidx.media3.exoplayer.MetadataRetriever
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
@ -33,9 +46,12 @@ import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.guava.asDeferred
|
import kotlinx.coroutines.guava.asDeferred
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
import org.oxycblt.auxio.image.CoverMode
|
import org.oxycblt.auxio.image.CoverMode
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
|
|
@ -46,8 +62,28 @@ constructor(
|
||||||
private val imageSettings: ImageSettings,
|
private val imageSettings: ImageSettings,
|
||||||
private val mediaSourceFactory: MediaSource.Factory
|
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 {
|
try {
|
||||||
when (imageSettings.coverMode) {
|
when (imageSettings.coverMode) {
|
||||||
CoverMode.OFF -> null
|
CoverMode.OFF -> null
|
||||||
|
|
@ -123,8 +159,61 @@ constructor(
|
||||||
return stream
|
return stream
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
private suspend fun extractMediaStoreCover(album: Album) =
|
private suspend fun extractMediaStoreCover(album: Album) =
|
||||||
// Eliminate any chance that this blocking call might mess up the loading process
|
// Eliminate any chance that this blocking call might mess up the loading process
|
||||||
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
data class BasicHeader(@StringRes override val titleRes: Int) : Header
|
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 android.view.View
|
||||||
import androidx.annotation.MenuRes
|
import androidx.annotation.MenuRes
|
||||||
import androidx.appcompat.widget.PopupMenu
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.core.internal.view.SupportMenu
|
|
||||||
import androidx.core.view.MenuCompat
|
import androidx.core.view.MenuCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
|
|
@ -30,8 +29,8 @@ import org.oxycblt.auxio.MainFragmentDirections
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
import org.oxycblt.auxio.navigation.MainNavigationAction
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
|
|
||||||
|
|
@ -59,7 +58,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
*/
|
*/
|
||||||
abstract fun onRealClick(item: T)
|
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()) {
|
if (selectionModel.selected.value.isNotEmpty()) {
|
||||||
// Map clicking an item to selecting an item when items are already selected.
|
// Map clicking an item to selecting an item when items are already selected.
|
||||||
selectionModel.select(item)
|
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)
|
selectionModel.select(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +81,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
* @param song The [Song] to create the menu for.
|
* @param song The [Song] to create the menu for.
|
||||||
*/
|
*/
|
||||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) {
|
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) {
|
openMusicMenuImpl(anchor, menuRes) {
|
||||||
when (it.itemId) {
|
when (it.itemId) {
|
||||||
|
|
@ -100,6 +99,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
R.id.action_go_album -> {
|
R.id.action_go_album -> {
|
||||||
navModel.exploreNavigateTo(song.album)
|
navModel.exploreNavigateTo(song.album)
|
||||||
}
|
}
|
||||||
|
R.id.action_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(song)
|
||||||
|
}
|
||||||
R.id.action_song_detail -> {
|
R.id.action_song_detail -> {
|
||||||
navModel.mainNavigateTo(
|
navModel.mainNavigateTo(
|
||||||
MainNavigationAction.Directions(
|
MainNavigationAction.Directions(
|
||||||
|
|
@ -121,7 +123,7 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
* @param album The [Album] to create the menu for.
|
* @param album The [Album] to create the menu for.
|
||||||
*/
|
*/
|
||||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) {
|
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) {
|
openMusicMenuImpl(anchor, menuRes) {
|
||||||
when (it.itemId) {
|
when (it.itemId) {
|
||||||
|
|
@ -142,6 +144,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
R.id.action_go_artist -> {
|
R.id.action_go_artist -> {
|
||||||
navModel.exploreNavigateToParentArtist(album)
|
navModel.exploreNavigateToParentArtist(album)
|
||||||
}
|
}
|
||||||
|
R.id.action_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(album)
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
error("Unexpected menu item selected")
|
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.
|
* @param artist The [Artist] to create the menu for.
|
||||||
*/
|
*/
|
||||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) {
|
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) {
|
openMusicMenuImpl(anchor, menuRes) {
|
||||||
when (it.itemId) {
|
when (it.itemId) {
|
||||||
|
|
@ -176,6 +181,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
playbackModel.addToQueue(artist)
|
playbackModel.addToQueue(artist)
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
}
|
}
|
||||||
|
R.id.action_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(artist)
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
error("Unexpected menu item selected")
|
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.
|
* @param genre The [Genre] to create the menu for.
|
||||||
*/
|
*/
|
||||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) {
|
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) {
|
openMusicMenuImpl(anchor, menuRes) {
|
||||||
when (it.itemId) {
|
when (it.itemId) {
|
||||||
|
|
@ -210,6 +218,49 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
playbackModel.addToQueue(genre)
|
playbackModel.addToQueue(genre)
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
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 -> {
|
else -> {
|
||||||
error("Unexpected menu item selected")
|
error("Unexpected menu item selected")
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +298,6 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||||
currentMenu =
|
currentMenu =
|
||||||
PopupMenu(requireContext(), anchor).apply {
|
PopupMenu(requireContext(), anchor).apply {
|
||||||
inflate(menuRes)
|
inflate(menuRes)
|
||||||
logD(menu is SupportMenu)
|
|
||||||
MenuCompat.setGroupDividerEnabled(menu, true)
|
MenuCompat.setGroupDividerEnabled(menu, true)
|
||||||
block()
|
block()
|
||||||
setOnDismissListener { currentMenu = null }
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface EditableListListener<in T> : ClickableListListener<T> {
|
interface EditableListListener {
|
||||||
/**
|
/**
|
||||||
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
|
* 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)
|
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.
|
* Binds this instance to a list item.
|
||||||
*
|
*
|
||||||
|
|
@ -78,13 +101,7 @@ interface EditableListListener<in T> : ClickableListListener<T> {
|
||||||
dragHandle: View
|
dragHandle: View
|
||||||
) {
|
) {
|
||||||
bind(item, viewHolder, bodyView)
|
bind(item, viewHolder, bodyView)
|
||||||
dragHandle.setOnTouchListener { _, motionEvent ->
|
bind(viewHolder, dragHandle)
|
||||||
dragHandle.performClick()
|
|
||||||
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
|
|
||||||
onPickUp(viewHolder)
|
|
||||||
true
|
|
||||||
} else false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Sort.Mode
|
import org.oxycblt.auxio.list.Sort.Mode
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.metadata.Date
|
import org.oxycblt.auxio.music.info.Date
|
||||||
import org.oxycblt.auxio.music.metadata.Disc
|
import org.oxycblt.auxio.music.info.Disc
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A sorting method.
|
* 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>) {
|
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>) {
|
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>) {
|
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>) {
|
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. */
|
/** Describes the type of data to sort with. */
|
||||||
sealed class Mode {
|
sealed interface Mode {
|
||||||
/** The integer representation of this sort 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. */
|
/** 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].
|
* Get a [Comparator] that sorts [Song]s according to this [Mode].
|
||||||
*
|
*
|
||||||
* @param direction The direction to sort in.
|
* @param direction The direction to sort in.
|
||||||
* @return A [Comparator] that can be used to sort a [Song] list according to this [Mode].
|
* @return A [Comparator] that can be used to sort a [Song] list according to this [Mode],
|
||||||
|
* or null to not sort at all.
|
||||||
*/
|
*/
|
||||||
open fun getSongComparator(direction: Direction): Comparator<Song> {
|
fun getSongComparator(direction: Direction): Comparator<Song>? = null
|
||||||
throw UnsupportedOperationException()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a [Comparator] that sorts [Album]s according to this [Mode].
|
* Get a [Comparator] that sorts [Album]s according to this [Mode].
|
||||||
*
|
*
|
||||||
* @param direction The direction to sort in.
|
* @param direction The direction to sort in.
|
||||||
* @return A [Comparator] that can be used to sort a [Album] list according to this [Mode].
|
* @return A [Comparator] that can be used to sort a [Album] list according to this [Mode],
|
||||||
|
* or null to not sort at all.
|
||||||
*/
|
*/
|
||||||
open fun getAlbumComparator(direction: Direction): Comparator<Album> {
|
fun getAlbumComparator(direction: Direction): Comparator<Album>? = null
|
||||||
throw UnsupportedOperationException()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a [Comparator] that sorts [Artist]s according to this [Mode].
|
* Return a [Comparator] that sorts [Artist]s according to this [Mode].
|
||||||
*
|
*
|
||||||
* @param direction The direction to sort in.
|
* @param direction The direction to sort in.
|
||||||
* @return A [Comparator] that can be used to sort a [Artist] list according to this [Mode].
|
* @return A [Comparator] that can be used to sort a [Artist] list according to this [Mode].
|
||||||
|
* or null to not sort at all.
|
||||||
*/
|
*/
|
||||||
open fun getArtistComparator(direction: Direction): Comparator<Artist> {
|
fun getArtistComparator(direction: Direction): Comparator<Artist>? = null
|
||||||
throw UnsupportedOperationException()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a [Comparator] that sorts [Genre]s according to this [Mode].
|
* Return a [Comparator] that sorts [Genre]s according to this [Mode].
|
||||||
*
|
*
|
||||||
* @param direction The direction to sort in.
|
* @param direction The direction to sort in.
|
||||||
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
|
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
|
||||||
|
* or null to not sort at all.
|
||||||
*/
|
*/
|
||||||
open fun getGenreComparator(direction: Direction): Comparator<Genre> {
|
fun getGenreComparator(direction: Direction): Comparator<Genre>? = null
|
||||||
throw UnsupportedOperationException()
|
|
||||||
}
|
/**
|
||||||
|
* 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.
|
* Sort by the item's name.
|
||||||
*
|
*
|
||||||
* @see Music.sortName
|
* @see Music.name
|
||||||
*/
|
*/
|
||||||
object ByName : Mode() {
|
object ByName : Mode {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
get() = IntegerTable.SORT_BY_NAME
|
get() = IntegerTable.SORT_BY_NAME
|
||||||
|
|
||||||
|
|
@ -223,14 +229,17 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
||||||
|
|
||||||
override fun getGenreComparator(direction: Direction) =
|
override fun getGenreComparator(direction: Direction) =
|
||||||
compareByDynamic(direction, BasicComparator.GENRE)
|
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.
|
* 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
|
override val intCode: Int
|
||||||
get() = IntegerTable.SORT_BY_ALBUM
|
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].
|
* 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
|
override val intCode: Int
|
||||||
get() = IntegerTable.SORT_BY_ARTIST
|
get() = IntegerTable.SORT_BY_ARTIST
|
||||||
|
|
||||||
|
|
@ -279,7 +288,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
||||||
* @see Song.date
|
* @see Song.date
|
||||||
* @see Album.dates
|
* @see Album.dates
|
||||||
*/
|
*/
|
||||||
object ByDate : Mode() {
|
object ByDate : Mode {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
get() = IntegerTable.SORT_BY_YEAR
|
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. */
|
/** Sort by the duration of an item. */
|
||||||
object ByDuration : Mode() {
|
object ByDuration : Mode {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
get() = IntegerTable.SORT_BY_DURATION
|
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> =
|
override fun getGenreComparator(direction: Direction): Comparator<Genre> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.GENRE))
|
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
|
* @see MusicParent.songs
|
||||||
*/
|
*/
|
||||||
object ByCount : Mode() {
|
object ByCount : Mode {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
get() = IntegerTable.SORT_BY_COUNT
|
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> =
|
override fun getGenreComparator(direction: Direction): Comparator<Genre> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.GENRE))
|
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
|
* @see Song.disc
|
||||||
*/
|
*/
|
||||||
object ByDisc : Mode() {
|
object ByDisc : Mode {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
get() = IntegerTable.SORT_BY_DISC
|
get() = IntegerTable.SORT_BY_DISC
|
||||||
|
|
||||||
|
|
@ -376,7 +395,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
||||||
*
|
*
|
||||||
* @see Song.track
|
* @see Song.track
|
||||||
*/
|
*/
|
||||||
object ByTrack : Mode() {
|
object ByTrack : Mode {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
get() = IntegerTable.SORT_BY_TRACK
|
get() = IntegerTable.SORT_BY_TRACK
|
||||||
|
|
||||||
|
|
@ -396,7 +415,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
||||||
* @see Song.dateAdded
|
* @see Song.dateAdded
|
||||||
* @see Album.dates
|
* @see Album.dates
|
||||||
*/
|
*/
|
||||||
object ByDateAdded : Mode() {
|
object ByDateAdded : Mode {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
get() = IntegerTable.SORT_BY_DATE_ADDED
|
get() = IntegerTable.SORT_BY_DATE_ADDED
|
||||||
|
|
||||||
|
|
@ -413,176 +432,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
||||||
compareBy(BasicComparator.ALBUM))
|
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 {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Convert a [Mode] integer representation into an instance.
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
sealed class UpdateInstructions {
|
sealed interface UpdateInstructions {
|
||||||
/** Use an asynchronous diff. Useful for unpredictable updates, but looks chaotic and janky. */
|
/** 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].
|
* Visually replace all items from a given point. More visually coherent than [Diff].
|
||||||
*
|
*
|
||||||
* @param from The index at which to start replacing items (inclusive)
|
* @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.
|
* Add a new set of items.
|
||||||
|
|
@ -79,7 +79,7 @@ sealed class UpdateInstructions {
|
||||||
* @param at The position at which to add.
|
* @param at The position at which to add.
|
||||||
* @param size The amount of items 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.
|
* Move one item to another location.
|
||||||
|
|
@ -87,14 +87,15 @@ sealed class UpdateInstructions {
|
||||||
* @param from The index of the item to move.
|
* @param from The index of the item to move.
|
||||||
* @param to The index to move the item to.
|
* @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.
|
* Remove an item.
|
||||||
*
|
*
|
||||||
* @param at The location that the item should be removed from.
|
* @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 -> {
|
is UpdateInstructions.Remove -> {
|
||||||
currentList = newList
|
currentList = newList
|
||||||
updateCallback.onRemoved(instructions.at, 1)
|
updateCallback.onRemoved(instructions.at, instructions.size)
|
||||||
callback?.invoke()
|
callback?.invoke()
|
||||||
}
|
}
|
||||||
is UpdateInstructions.Diff,
|
is UpdateInstructions.Diff,
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,12 @@ import android.util.AttributeSet
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView] with a few QoL extensions, such as:
|
* A [RecyclerView] with a few QoL extensions, such as:
|
||||||
* - Automatic edge-to-edge support
|
* - Automatic edge-to-edge support
|
||||||
* - Adapter-based [SpanSizeLookup] implementation
|
|
||||||
* - Automatic [setHasFixedSize] setup
|
* - Automatic [setHasFixedSize] setup
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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,
|
// Auxio's non-dialog RecyclerViews never change their size based on adapter contents,
|
||||||
// so we can enable fixed-size optimizations.
|
// so we can enable fixed-size optimizations.
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
addItemDecoration(HeaderItemDecoration(context))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final override fun setHasFixedSize(hasFixedSize: Boolean) {
|
final override fun setHasFixedSize(hasFixedSize: Boolean) {
|
||||||
|
|
@ -65,36 +62,4 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
|
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
|
||||||
return insets
|
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 android.view.View
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.divider.MaterialDivider
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemHeaderBinding
|
import org.oxycblt.auxio.databinding.ItemHeaderBinding
|
||||||
import org.oxycblt.auxio.databinding.ItemParentBinding
|
import org.oxycblt.auxio.databinding.ItemParentBinding
|
||||||
import org.oxycblt.auxio.databinding.ItemSongBinding
|
import org.oxycblt.auxio.databinding.ItemSongBinding
|
||||||
import org.oxycblt.auxio.list.BasicHeader
|
import org.oxycblt.auxio.list.BasicHeader
|
||||||
|
import org.oxycblt.auxio.list.Divider
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
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>) {
|
fun bind(song: Song, listener: SelectableListListener<Song>) {
|
||||||
listener.bind(song, this, menuButton = binding.songMenu)
|
listener.bind(song, this, menuButton = binding.songMenu)
|
||||||
binding.songAlbumCover.bind(song)
|
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)
|
binding.songInfo.text = song.artists.resolveNames(binding.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,8 +82,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleDiffCallback<Song>() {
|
object : SimpleDiffCallback<Song>() {
|
||||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.name == newItem.name && oldItem.artists.areNamesTheSame(newItem.artists)
|
||||||
oldItem.artists.areRawNamesTheSame(newItem.artists)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +103,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
|
||||||
fun bind(album: Album, listener: SelectableListListener<Album>) {
|
fun bind(album: Album, listener: SelectableListListener<Album>) {
|
||||||
listener.bind(album, this, menuButton = binding.parentMenu)
|
listener.bind(album, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(album)
|
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)
|
binding.parentInfo.text = album.artists.resolveNames(binding.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,8 +132,8 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleDiffCallback<Album>() {
|
object : SimpleDiffCallback<Album>() {
|
||||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.name == newItem.name &&
|
||||||
oldItem.artists.areRawNamesTheSame(newItem.artists) &&
|
oldItem.artists.areNamesTheSame(newItem.artists) &&
|
||||||
oldItem.releaseType == newItem.releaseType
|
oldItem.releaseType == newItem.releaseType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,17 +155,16 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
||||||
fun bind(artist: Artist, listener: SelectableListListener<Artist>) {
|
fun bind(artist: Artist, listener: SelectableListListener<Artist>) {
|
||||||
listener.bind(artist, this, menuButton = binding.parentMenu)
|
listener.bind(artist, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(artist)
|
binding.parentImage.bind(artist)
|
||||||
binding.parentName.text = artist.resolveName(binding.context)
|
binding.parentName.text = artist.name.resolve(binding.context)
|
||||||
binding.parentInfo.text =
|
binding.parentInfo.text =
|
||||||
if (artist.songs.isNotEmpty()) {
|
binding.context.getString(
|
||||||
binding.context.getString(
|
R.string.fmt_two,
|
||||||
R.string.fmt_two,
|
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
|
||||||
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))
|
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
|
||||||
} else {
|
} else {
|
||||||
// Artist has no songs, only display an album count.
|
binding.context.getString(R.string.def_song_count)
|
||||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
|
|
@ -193,7 +193,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleDiffCallback<Artist>() {
|
object : SimpleDiffCallback<Artist>() {
|
||||||
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
|
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.name == newItem.name &&
|
||||||
oldItem.albums.size == newItem.albums.size &&
|
oldItem.albums.size == newItem.albums.size &&
|
||||||
oldItem.songs.size == newItem.songs.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>) {
|
fun bind(genre: Genre, listener: SelectableListListener<Genre>) {
|
||||||
listener.bind(genre, this, menuButton = binding.parentMenu)
|
listener.bind(genre, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(genre)
|
binding.parentImage.bind(genre)
|
||||||
binding.parentName.text = genre.resolveName(binding.context)
|
binding.parentName.text = genre.name.resolve(binding.context)
|
||||||
binding.parentInfo.text =
|
binding.parentInfo.text =
|
||||||
binding.context.getString(
|
binding.context.getString(
|
||||||
R.string.fmt_two,
|
R.string.fmt_two,
|
||||||
|
|
@ -248,8 +248,66 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleDiffCallback<Genre>() {
|
object : SimpleDiffCallback<Genre>() {
|
||||||
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
|
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
|
||||||
oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleDiffCallback<BasicHeader>() {
|
object : SimpleDiffCallback<BasicHeader>() {
|
||||||
override fun areContentsTheSame(
|
override fun areContentsTheSame(oldItem: BasicHeader, newItem: BasicHeader) =
|
||||||
oldItem: BasicHeader,
|
oldItem.titleRes == newItem.titleRes
|
||||||
newItem: BasicHeader
|
}
|
||||||
): Boolean = 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.appcompat.widget.Toolbar
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
|
|
@ -35,22 +36,16 @@ import org.oxycblt.auxio.util.showToast
|
||||||
abstract class SelectionFragment<VB : ViewBinding> :
|
abstract class SelectionFragment<VB : ViewBinding> :
|
||||||
ViewBindingFragment<VB>(), Toolbar.OnMenuItemClickListener {
|
ViewBindingFragment<VB>(), Toolbar.OnMenuItemClickListener {
|
||||||
protected abstract val selectionModel: SelectionViewModel
|
protected abstract val selectionModel: SelectionViewModel
|
||||||
|
protected abstract val musicModel: MusicViewModel
|
||||||
protected abstract val playbackModel: PlaybackViewModel
|
protected abstract val playbackModel: PlaybackViewModel
|
||||||
|
|
||||||
/**
|
open fun getSelectionToolbar(binding: VB): Toolbar? = null
|
||||||
* 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
|
|
||||||
|
|
||||||
override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
getSelectionToolbar(binding)?.apply {
|
getSelectionToolbar(binding)?.apply {
|
||||||
// Add cancel and menu item listeners to manage what occurs with the selection.
|
// Add cancel and menu item listeners to manage what occurs with the selection.
|
||||||
setOnSelectionCancelListener { selectionModel.consume() }
|
setNavigationOnClickListener { selectionModel.drop() }
|
||||||
setOnMenuItemClickListener(this@SelectionFragment)
|
setOnMenuItemClickListener(this@SelectionFragment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,21 +58,25 @@ abstract class SelectionFragment<VB : ViewBinding> :
|
||||||
override fun onMenuItemClick(item: MenuItem) =
|
override fun onMenuItemClick(item: MenuItem) =
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.action_selection_play_next -> {
|
R.id.action_selection_play_next -> {
|
||||||
playbackModel.playNext(selectionModel.consume())
|
playbackModel.playNext(selectionModel.take())
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_selection_queue_add -> {
|
R.id.action_selection_queue_add -> {
|
||||||
playbackModel.addToQueue(selectionModel.consume())
|
playbackModel.addToQueue(selectionModel.take())
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_selection_playlist_add -> {
|
||||||
|
musicModel.addToPlaylist(selectionModel.take())
|
||||||
|
true
|
||||||
|
}
|
||||||
R.id.action_selection_play -> {
|
R.id.action_selection_play -> {
|
||||||
playbackModel.play(selectionModel.consume())
|
playbackModel.play(selectionModel.take())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_selection_shuffle -> {
|
R.id.action_selection_shuffle -> {
|
||||||
playbackModel.shuffle(selectionModel.consume())
|
playbackModel.shuffle(selectionModel.take())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.model.Library
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewModel] that manages the current selection.
|
* A [ViewModel] that manages the current selection.
|
||||||
|
|
@ -32,38 +31,42 @@ import org.oxycblt.auxio.music.model.Library
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
class SelectionViewModel
|
||||||
ViewModel(), MusicRepository.Listener {
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val musicRepository: MusicRepository,
|
||||||
|
private val musicSettings: MusicSettings
|
||||||
|
) : ViewModel(), MusicRepository.UpdateListener {
|
||||||
private val _selected = MutableStateFlow(listOf<Music>())
|
private val _selected = MutableStateFlow(listOf<Music>())
|
||||||
/** the currently selected items. These are ordered in earliest selected and latest selected. */
|
/** the currently selected items. These are ordered in earliest selected and latest selected. */
|
||||||
val selected: StateFlow<List<Music>>
|
val selected: StateFlow<List<Music>>
|
||||||
get() = _selected
|
get() = _selected
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: Library?) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (library == null) {
|
if (!changes.deviceLibrary) return
|
||||||
return
|
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||||
}
|
val userLibrary = musicRepository.userLibrary ?: return
|
||||||
|
|
||||||
// Sanitize the selection to remove items that no longer exist and thus
|
// Sanitize the selection to remove items that no longer exist and thus
|
||||||
// won't appear in any list.
|
// won't appear in any list.
|
||||||
_selected.value =
|
_selected.value =
|
||||||
_selected.value.mapNotNull {
|
_selected.value.mapNotNull {
|
||||||
when (it) {
|
when (it) {
|
||||||
is Song -> library.sanitize(it)
|
is Song -> deviceLibrary.findSong(it.uid)
|
||||||
is Album -> library.sanitize(it)
|
is Album -> deviceLibrary.findAlbum(it.uid)
|
||||||
is Artist -> library.sanitize(it)
|
is Artist -> deviceLibrary.findArtist(it.uid)
|
||||||
is Genre -> library.sanitize(it)
|
is Genre -> deviceLibrary.findGenre(it.uid)
|
||||||
|
is Playlist -> userLibrary.findPlaylist(it.uid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.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.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import androidx.room.TypeConverter
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.text.CollationKey
|
|
||||||
import java.text.Collator
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlinx.parcelize.IgnoredOnParcel
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.metadata.Date
|
import org.oxycblt.auxio.music.fs.MimeType
|
||||||
import org.oxycblt.auxio.music.metadata.Disc
|
import org.oxycblt.auxio.music.fs.Path
|
||||||
import org.oxycblt.auxio.music.metadata.ReleaseType
|
import org.oxycblt.auxio.music.info.Date
|
||||||
import org.oxycblt.auxio.music.storage.MimeType
|
import org.oxycblt.auxio.music.info.Disc
|
||||||
import org.oxycblt.auxio.music.storage.Path
|
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.concatLocalized
|
||||||
import org.oxycblt.auxio.util.toUuidOrNull
|
import org.oxycblt.auxio.util.toUuidOrNull
|
||||||
|
|
||||||
|
|
@ -51,35 +51,8 @@ sealed interface Music : Item {
|
||||||
*/
|
*/
|
||||||
val uid: UID
|
val uid: UID
|
||||||
|
|
||||||
/**
|
/** The [Name] of the music item. */
|
||||||
* The raw name of this item as it was extracted from the file-system. Will be null if the
|
val name: Name
|
||||||
* 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?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A unique identifier for a piece of music.
|
* A unique identifier for a piece of music.
|
||||||
|
|
@ -136,7 +109,25 @@ sealed interface Music : Item {
|
||||||
MUSICBRAINZ("org.musicbrainz")
|
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 {
|
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,
|
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
|
||||||
* unlikely-to-change metadata of the music.
|
* unlikely-to-change metadata of the music.
|
||||||
|
|
@ -189,7 +180,7 @@ sealed interface Music : Item {
|
||||||
* file.
|
* file.
|
||||||
* @return A new MusicBrainz-style [UID].
|
* @return A new MusicBrainz-style [UID].
|
||||||
*/
|
*/
|
||||||
fun musicBrainz(mode: MusicMode, mbid: UUID): UID = UID(Format.MUSICBRAINZ, mode, mbid)
|
fun musicBrainz(mode: MusicMode, mbid: UUID) = UID(Format.MUSICBRAINZ, mode, mbid)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a [UID]'s string representation back into a concrete [UID] instance.
|
* Convert a [UID]'s string representation back into a concrete [UID] instance.
|
||||||
|
|
@ -357,83 +348,39 @@ interface Genre : MusicParent {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A black-box datatype for a variation of music names that is suitable for music-oriented sorting.
|
* A playlist.
|
||||||
* It will automatically handle articles like "The" and numeric components like "An".
|
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SortName(name: String, musicSettings: MusicSettings) : Comparable<SortName> {
|
interface Playlist : MusicParent {
|
||||||
private val collationKey: CollationKey
|
/** The albums indirectly linked to by the [Song]s of this [Playlist]. */
|
||||||
val thumbString: String?
|
val albums: List<Album>
|
||||||
|
/** The total duration of the songs in this genre, in milliseconds. */
|
||||||
init {
|
val durationMs: Long
|
||||||
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+")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run [Music.resolveName] on each instance in the given list and concatenate them into a [String]
|
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
|
||||||
* in a localized manner.
|
* localized manner.
|
||||||
*
|
*
|
||||||
* @param context [Context] required
|
* @param context [Context] required
|
||||||
* @return A concatenated string.
|
* @return A concatenated string.
|
||||||
*/
|
*/
|
||||||
fun <T : Music> List<T>.resolveNames(context: Context) =
|
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
|
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
|
||||||
* display information of an item must be compared without a context.
|
* information of an item must be compared without a context.
|
||||||
*
|
*
|
||||||
* @param other The list of items to compare to.
|
* @param other The list of items to compare to.
|
||||||
* @return True if they are the same (by [Music.rawName]), false otherwise.
|
* @return True if they are the same (by [Music.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)) {
|
for (i in 0 until max(size, other.size)) {
|
||||||
val a = getOrNull(i) ?: return false
|
val a = getOrNull(i) ?: return false
|
||||||
val b = other.getOrNull(i) ?: return false
|
val b = other.getOrNull(i) ?: return false
|
||||||
if (a.rawName != b.rawName) {
|
if (a.name != b.name) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ enum class MusicMode {
|
||||||
/** Configure with respect to [Artist] instances. */
|
/** Configure with respect to [Artist] instances. */
|
||||||
ARTISTS,
|
ARTISTS,
|
||||||
/** Configure with respect to [Genre] instances. */
|
/** Configure with respect to [Genre] instances. */
|
||||||
GENRES;
|
GENRES,
|
||||||
|
/** Configure with respect to [Playlist] instances. */
|
||||||
|
PLAYLISTS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The integer representation of this instance.
|
* The integer representation of this instance.
|
||||||
|
|
@ -47,6 +49,7 @@ enum class MusicMode {
|
||||||
ALBUMS -> IntegerTable.MUSIC_MODE_ALBUMS
|
ALBUMS -> IntegerTable.MUSIC_MODE_ALBUMS
|
||||||
ARTISTS -> IntegerTable.MUSIC_MODE_ARTISTS
|
ARTISTS -> IntegerTable.MUSIC_MODE_ARTISTS
|
||||||
GENRES -> IntegerTable.MUSIC_MODE_GENRES
|
GENRES -> IntegerTable.MUSIC_MODE_GENRES
|
||||||
|
PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -63,6 +66,7 @@ enum class MusicMode {
|
||||||
IntegerTable.MUSIC_MODE_ALBUMS -> ALBUMS
|
IntegerTable.MUSIC_MODE_ALBUMS -> ALBUMS
|
||||||
IntegerTable.MUSIC_MODE_ARTISTS -> ARTISTS
|
IntegerTable.MUSIC_MODE_ARTISTS -> ARTISTS
|
||||||
IntegerTable.MUSIC_MODE_GENRES -> GENRES
|
IntegerTable.MUSIC_MODE_GENRES -> GENRES
|
||||||
|
IntegerTable.MUSIC_MODE_PLAYLISTS -> PLAYLISTS
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,10 @@ import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import org.oxycblt.auxio.music.system.Indexer
|
|
||||||
import org.oxycblt.auxio.music.system.IndexerImpl
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface MusicModule {
|
interface MusicModule {
|
||||||
@Singleton @Binds fun repository(musicRepository: MusicRepositoryImpl): MusicRepository
|
@Singleton @Binds fun repository(musicRepository: MusicRepositoryImpl): MusicRepository
|
||||||
@Singleton @Binds fun indexer(indexer: IndexerImpl): Indexer
|
|
||||||
@Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings
|
@Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,75 +18,452 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music
|
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 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
|
* Music information is loaded in-memory by this repository using an [IndexingWorker]. Changes in
|
||||||
* generally recommended to use this over Indexer to keep track of the library state, as the
|
* music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
|
||||||
* interface will be less volatile.
|
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface MusicRepository {
|
interface MusicRepository {
|
||||||
/**
|
/** The current music information found on the device. */
|
||||||
* The current [Library]. May be null if a [Library] has not been successfully loaded yet. This
|
val deviceLibrary: DeviceLibrary?
|
||||||
* can change, so it's highly recommended to not access this directly and instead rely on
|
/** The current user-defined music information. */
|
||||||
* [Listener].
|
val userLibrary: UserLibrary?
|
||||||
*/
|
/** The current state of music loading. Null if no load has occurred yet. */
|
||||||
var library: Library?
|
val indexingState: IndexingState?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a [Listener] to this instance. This can be used to receive changes in the music library.
|
* Add an [UpdateListener] to receive updates from this instance.
|
||||||
* Will invoke all [Listener] methods to initialize the instance with the current state.
|
|
||||||
*
|
*
|
||||||
* @param listener The [Listener] to add.
|
* @param listener The [UpdateListener] to add.
|
||||||
* @see Listener
|
|
||||||
*/
|
*/
|
||||||
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
|
* @param listener The [UpdateListener] to remove.
|
||||||
* the first place.
|
|
||||||
* @see Listener
|
|
||||||
*/
|
*/
|
||||||
fun removeListener(listener: Listener)
|
fun removeUpdateListener(listener: UpdateListener)
|
||||||
|
|
||||||
/** A listener for changes in [MusicRepository] */
|
/**
|
||||||
interface Listener {
|
* Add an [IndexingListener] to receive updates from this instance.
|
||||||
|
*
|
||||||
|
* @param listener The [UpdateListener] to add.
|
||||||
|
*/
|
||||||
|
fun addIndexingListener(listener: IndexingListener)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an [IndexingListener] such that it does not receive any further updates from this
|
||||||
|
* instance.
|
||||||
|
*
|
||||||
|
* @param listener The [IndexingListener] to remove.
|
||||||
|
*/
|
||||||
|
fun removeIndexingListener(listener: IndexingListener)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an [IndexingWorker] to handle loading operations. Will do nothing if one is already
|
||||||
|
* registered.
|
||||||
|
*
|
||||||
|
* @param worker The [IndexingWorker] to register.
|
||||||
|
*/
|
||||||
|
fun registerWorker(worker: IndexingWorker)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister an [IndexingWorker] and drop any work currently being done by it. Does nothing if
|
||||||
|
* given [IndexingWorker] is not the currently registered instance.
|
||||||
|
*
|
||||||
|
* @param worker The [IndexingWorker] to unregister.
|
||||||
|
*/
|
||||||
|
fun unregisterWorker(worker: IndexingWorker)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generically search for the [Music] associated with the given [Music.UID]. Note that this
|
||||||
|
* method is much slower that type-specific find implementations, so this should only be used if
|
||||||
|
* the type of music being searched for is entirely unknown.
|
||||||
|
*
|
||||||
|
* @param uid The [Music.UID] to search for.
|
||||||
|
* @return The expected [Music] information, or null if it could not be found.
|
||||||
|
*/
|
||||||
|
fun find(uid: Music.UID): Music?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new [Playlist] of the given [Song]s.
|
||||||
|
*
|
||||||
|
* @param name The name of the new [Playlist].
|
||||||
|
* @param songs The songs to populate the new [Playlist] with.
|
||||||
|
*/
|
||||||
|
suspend fun createPlaylist(name: String, songs: List<Song>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a [Playlist].
|
||||||
|
*
|
||||||
|
* @param playlist The [Playlist] to rename.
|
||||||
|
* @param name The name of the new [Playlist].
|
||||||
|
*/
|
||||||
|
suspend fun renamePlaylist(playlist: Playlist, name: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a [Playlist].
|
||||||
|
*
|
||||||
|
* @param playlist The playlist to delete.
|
||||||
|
*/
|
||||||
|
suspend fun deletePlaylist(playlist: Playlist)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the given [Song]s to a [Playlist].
|
||||||
|
*
|
||||||
|
* @param songs The [Song]s to add to the [Playlist].
|
||||||
|
* @param playlist The [Playlist] to add to.
|
||||||
|
*/
|
||||||
|
suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the [Song]s of a [Playlist].
|
||||||
|
*
|
||||||
|
* @param playlist The [Playlist] to update.
|
||||||
|
* @param songs The new [Song]s to be contained in the [Playlist].
|
||||||
|
*/
|
||||||
|
suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request that a music loading operation is started by the current [IndexingWorker]. Does
|
||||||
|
* nothing if one is not available.
|
||||||
|
*
|
||||||
|
* @param withCache Whether to load with the music cache or not.
|
||||||
|
*/
|
||||||
|
fun requestIndex(withCache: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the music library. Any prior loads will be canceled.
|
||||||
|
*
|
||||||
|
* @param worker The [IndexingWorker] to perform the work with.
|
||||||
|
* @param withCache Whether to load with the music cache or not.
|
||||||
|
* @return The top-level music loading [Job] started.
|
||||||
|
*/
|
||||||
|
fun index(worker: IndexingWorker, withCache: Boolean): Job
|
||||||
|
|
||||||
|
/** A listener for changes to the stored music information. */
|
||||||
|
interface UpdateListener {
|
||||||
/**
|
/**
|
||||||
* Called when the current [Library] has changed.
|
* Called when a change to the stored music information occurs.
|
||||||
*
|
*
|
||||||
* @param library The new [Library], or null if no [Library] has been loaded yet.
|
* @param changes The [Changes] that have occurred.
|
||||||
*/
|
*/
|
||||||
fun onLibraryChanged(library: Library?)
|
fun onMusicChanges(changes: Changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags indicating which kinds of music information changed.
|
||||||
|
*
|
||||||
|
* @param deviceLibrary Whether the current [DeviceLibrary] has changed.
|
||||||
|
* @param userLibrary Whether the current [Playlist]s have changed.
|
||||||
|
*/
|
||||||
|
data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
|
||||||
|
|
||||||
|
/** A listener for events in the music loading process. */
|
||||||
|
interface IndexingListener {
|
||||||
|
/** Called when the music loading state changed. */
|
||||||
|
fun onIndexingStateChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A persistent worker that can load music in the background. */
|
||||||
|
interface IndexingWorker {
|
||||||
|
/** A [Context] required to read device storage */
|
||||||
|
val context: Context
|
||||||
|
|
||||||
|
/** The [CoroutineScope] to perform coroutine music loading work on. */
|
||||||
|
val scope: CoroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request that the music loading process ([index]) should be started. Any prior loads
|
||||||
|
* should be canceled.
|
||||||
|
*
|
||||||
|
* @param withCache Whether to use the music cache when loading.
|
||||||
|
*/
|
||||||
|
fun requestIndex(withCache: Boolean)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MusicRepositoryImpl @Inject constructor() : MusicRepository {
|
class MusicRepositoryImpl
|
||||||
private val listeners = mutableListOf<MusicRepository.Listener>()
|
@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
|
@Volatile override var deviceLibrary: DeviceLibrary? = null
|
||||||
override var library: Library? = null
|
@Volatile override var userLibrary: MutableUserLibrary? = null
|
||||||
set(value) {
|
@Volatile private var previousCompletedState: IndexingState.Completed? = null
|
||||||
field = value
|
@Volatile private var currentIndexingState: IndexingState? = null
|
||||||
for (callback in listeners) {
|
override val indexingState: IndexingState?
|
||||||
callback.onLibraryChanged(library)
|
get() = currentIndexingState ?: previousCompletedState
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||||
|
updateListeners.add(listener)
|
||||||
|
listener.onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun removeUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||||
|
updateListeners.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun addIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||||
|
indexingListeners.add(listener)
|
||||||
|
listener.onIndexingStateChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun removeIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||||
|
indexingListeners.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun registerWorker(worker: MusicRepository.IndexingWorker) {
|
||||||
|
if (indexingWorker != null) {
|
||||||
|
logW("Worker is already registered")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
indexingWorker = worker
|
||||||
|
if (indexingState == null) {
|
||||||
|
worker.requestIndex(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
|
||||||
|
if (indexingWorker !== worker) {
|
||||||
|
logW("Given worker did not match current worker")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
indexingWorker = null
|
||||||
|
currentIndexingState = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun find(uid: Music.UID) =
|
||||||
|
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
|
||||||
|
?: userLibrary?.findPlaylist(uid))
|
||||||
|
|
||||||
|
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
||||||
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
|
userLibrary.createPlaylist(name, songs)
|
||||||
|
notifyUserLibraryChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
||||||
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
|
userLibrary.renamePlaylist(playlist, name)
|
||||||
|
notifyUserLibraryChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deletePlaylist(playlist: Playlist) {
|
||||||
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
|
userLibrary.deletePlaylist(playlist)
|
||||||
|
notifyUserLibraryChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
||||||
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
|
userLibrary.addToPlaylist(playlist, songs)
|
||||||
|
notifyUserLibraryChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
||||||
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
|
userLibrary.rewritePlaylist(playlist, songs)
|
||||||
|
notifyUserLibraryChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun notifyUserLibraryChange() {
|
||||||
|
for (listener in updateListeners) {
|
||||||
|
listener.onMusicChanges(
|
||||||
|
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun requestIndex(withCache: Boolean) {
|
||||||
|
indexingWorker?.requestIndex(withCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
|
||||||
|
worker.scope.launch {
|
||||||
|
try {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
indexImpl(worker, withCache)
|
||||||
|
logD(
|
||||||
|
"Music indexing completed successfully in " +
|
||||||
|
"${System.currentTimeMillis() - start}ms")
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// Got cancelled, propagate upwards to top-level co-routine.
|
||||||
|
logD("Loading routine was cancelled")
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Music loading process failed due to something we have not handled.
|
||||||
|
logE("Music indexing failed")
|
||||||
|
logE(e.stackTraceToString())
|
||||||
|
emitComplete(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) {
|
||||||
override fun addListener(listener: MusicRepository.Listener) {
|
if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) ==
|
||||||
listener.onLibraryChanged(library)
|
PackageManager.PERMISSION_DENIED) {
|
||||||
listeners.add(listener)
|
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
|
@Synchronized
|
||||||
override fun removeListener(listener: MusicRepository.Listener) {
|
private fun emitData(deviceLibrary: DeviceLibrary, userLibrary: MutableUserLibrary) {
|
||||||
listeners.remove(listener)
|
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 javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.music.storage.Directory
|
import org.oxycblt.auxio.music.fs.Directory
|
||||||
import org.oxycblt.auxio.music.storage.MusicDirectories
|
import org.oxycblt.auxio.music.fs.MusicDirectories
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
|
|
||||||
|
|
@ -55,11 +55,13 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
||||||
var artistSort: Sort
|
var artistSort: Sort
|
||||||
/** The [Sort] mode used in [Genre] lists. */
|
/** The [Sort] mode used in [Genre] lists. */
|
||||||
var genreSort: Sort
|
var genreSort: Sort
|
||||||
|
/** The [Sort] mode used in [Playlist] lists. */
|
||||||
|
var playlistSort: Sort
|
||||||
/** The [Sort] mode used in an [Album]'s [Song] list. */
|
/** The [Sort] mode used in an [Album]'s [Song] list. */
|
||||||
var albumSongSort: Sort
|
var albumSongSort: Sort
|
||||||
/** The [Sort] mode used in an [Artist]'s [Song] list. */
|
/** The [Sort] mode used in an [Artist]'s [Song] list. */
|
||||||
var artistSongSort: Sort
|
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
|
var genreSongSort: Sort
|
||||||
|
|
||||||
interface Listener {
|
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
|
override var albumSongSort: Sort
|
||||||
get() {
|
get() {
|
||||||
var sort =
|
var sort =
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,15 @@
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MusicViewModel @Inject constructor(private val indexer: Indexer) :
|
class MusicViewModel
|
||||||
ViewModel(), Indexer.Listener {
|
@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. */
|
/** 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)
|
private val _statistics = MutableStateFlow<Statistics?>(null)
|
||||||
/** [Statistics] about the last completed music load. */
|
/** [Statistics] about the last completed music load. */
|
||||||
val statistics: StateFlow<Statistics?>
|
val statistics: StateFlow<Statistics?>
|
||||||
get() = _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 {
|
init {
|
||||||
indexer.registerListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
|
musicRepository.addIndexingListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
indexer.unregisterListener(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
|
musicRepository.removeIndexingListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
_indexerState.value = state
|
if (!changes.deviceLibrary) return
|
||||||
if (state is Indexer.State.Complete) {
|
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||||
// New state is a completed library, update the statistics values.
|
_statistics.value =
|
||||||
val library = state.result.getOrNull() ?: return
|
Statistics(
|
||||||
_statistics.value =
|
deviceLibrary.songs.size,
|
||||||
Statistics(
|
deviceLibrary.albums.size,
|
||||||
library.songs.size,
|
deviceLibrary.artists.size,
|
||||||
library.albums.size,
|
deviceLibrary.genres.size,
|
||||||
library.artists.size,
|
deviceLibrary.songs.sumOf { it.durationMs })
|
||||||
library.genres.size,
|
}
|
||||||
library.songs.sumOf { it.durationMs })
|
|
||||||
}
|
override fun onIndexingStateChanged() {
|
||||||
|
_indexingState.value = musicRepository.indexingState
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Requests that the music library should be re-loaded while leveraging the cache. */
|
/** Requests that the music library should be re-loaded while leveraging the cache. */
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
indexer.requestReindex(true)
|
musicRepository.requestIndex(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Requests that the music library be re-loaded without the cache. */
|
/** Requests that the music library be re-loaded without the cache. */
|
||||||
fun rescan() {
|
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.RoomDatabase
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import androidx.room.TypeConverters
|
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.correctWhitespace
|
||||||
import org.oxycblt.auxio.music.metadata.splitEscaped
|
import org.oxycblt.auxio.music.metadata.splitEscaped
|
||||||
import org.oxycblt.auxio.music.model.RawSong
|
|
||||||
|
|
||||||
@Database(entities = [CachedSong::class], version = 27, exportSchema = false)
|
@Database(entities = [CachedSong::class], version = 27, exportSchema = false)
|
||||||
abstract class CacheDatabase : RoomDatabase() {
|
abstract class CacheDatabase : RoomDatabase() {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
package org.oxycblt.auxio.music.cache
|
package org.oxycblt.auxio.music.cache
|
||||||
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.music.model.RawSong
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 Auxio Project
|
* 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
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -16,60 +16,44 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.model
|
package org.oxycblt.auxio.music.device
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||||
import org.oxycblt.auxio.music.storage.useQuery
|
import org.oxycblt.auxio.music.fs.useQuery
|
||||||
import org.oxycblt.auxio.util.logD
|
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
|
* 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
|
* information. Instances are immutable. It's generally not expected to create this yourself and
|
||||||
* [MusicRepository].
|
* instead use [MusicRepository].
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart
|
* @author Alexander Capehart
|
||||||
*/
|
*/
|
||||||
interface Library {
|
interface DeviceLibrary {
|
||||||
/** All [Song]s in this [Library]. */
|
/** All [Song]s in this [DeviceLibrary]. */
|
||||||
val songs: List<Song>
|
val songs: List<Song>
|
||||||
/** All [Album]s in this [Library]. */
|
/** All [Album]s in this [DeviceLibrary]. */
|
||||||
val albums: List<Album>
|
val albums: List<Album>
|
||||||
/** All [Artist]s in this [Library]. */
|
/** All [Artist]s in this [DeviceLibrary]. */
|
||||||
val artists: List<Artist>
|
val artists: List<Artist>
|
||||||
/** All [Genre]s in this [Library]. */
|
/** All [Genre]s in this [DeviceLibrary]. */
|
||||||
val genres: List<Genre>
|
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.
|
* @param uid The [Music.UID] to search for.
|
||||||
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or
|
* @return The corresponding [Song], or null if one was not found.
|
||||||
* the [Music.UID] did not correspond to a [T].
|
|
||||||
*/
|
*/
|
||||||
fun <T : Music> find(uid: Music.UID): T?
|
fun findSong(uid: Music.UID): Song?
|
||||||
|
|
||||||
/**
|
|
||||||
* 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?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
|
* 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?
|
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 {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Create an instance of [Library].
|
* Create an instance of [DeviceLibrary].
|
||||||
*
|
*
|
||||||
* @param rawSongs [RawSong]s to create the library out of.
|
* @param rawSongs [RawSong]s to create the library out of.
|
||||||
* @param settings [MusicSettings] required.
|
* @param settings [MusicSettings] required.
|
||||||
*/
|
*/
|
||||||
fun from(rawSongs: List<RawSong>, settings: MusicSettings): Library =
|
fun from(rawSongs: List<RawSong>, settings: MusicSettings): DeviceLibrary =
|
||||||
LibraryImpl(rawSongs, settings)
|
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 songs = buildSongs(rawSongs, settings)
|
||||||
override val albums = buildAlbums(songs, settings)
|
override val albums = buildAlbums(songs, settings)
|
||||||
override val artists = buildArtists(songs, albums, settings)
|
override val artists = buildArtists(songs, albums, settings)
|
||||||
override val genres = buildGenres(songs, settings)
|
override val genres = buildGenres(songs, settings)
|
||||||
|
|
||||||
// Use a mapping to make finding information based on it's UID much faster.
|
// Use a mapping to make finding information based on it's UID much faster.
|
||||||
private val uidMap = buildMap {
|
private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } }
|
||||||
songs.forEach { put(it.uid, it.finalize()) }
|
private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } }
|
||||||
albums.forEach { put(it.uid, it.finalize()) }
|
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
|
||||||
artists.forEach { put(it.uid, it.finalize()) }
|
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
|
||||||
genres.forEach { put(it.uid, it.finalize()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
override fun equals(other: Any?) =
|
||||||
other is Library &&
|
other is DeviceLibrary &&
|
||||||
other.songs == songs &&
|
other.songs == songs &&
|
||||||
other.albums == albums &&
|
other.albums == albums &&
|
||||||
other.artists == artists &&
|
other.artists == artists &&
|
||||||
|
|
@ -121,18 +143,10 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
|
||||||
return hashCode
|
return hashCode
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
override fun findSong(uid: Music.UID) = songUidMap[uid]
|
||||||
* Finds a [Music] item [T] in the library by it's [Music.UID].
|
override fun findAlbum(uid: Music.UID) = albumUidMap[uid]
|
||||||
*
|
override fun findArtist(uid: Music.UID) = artistUidMap[uid]
|
||||||
* @param uid The [Music.UID] to search for.
|
override fun findGenre(uid: Music.UID) = genreUidMap[uid]
|
||||||
* @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 findSongForUri(context: Context, uri: Uri) =
|
override fun findSongForUri(context: Context, uri: Uri) =
|
||||||
context.contentResolverSafe.useQuery(
|
context.contentResolverSafe.useQuery(
|
||||||
|
|
@ -156,7 +170,7 @@ private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Li
|
||||||
*/
|
*/
|
||||||
private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings) =
|
private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings) =
|
||||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||||
.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.
|
* Build a list of [Album]s from the given [Song]s.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 Auxio Project
|
* 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
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -16,17 +16,15 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.metadata
|
package org.oxycblt.auxio.music.device
|
||||||
|
|
||||||
/**
|
import dagger.Binds
|
||||||
* Defines the allowed separator characters that can be used to delimit multi-value tags.
|
import dagger.Module
|
||||||
*
|
import dagger.hilt.InstallIn
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
import dagger.hilt.components.SingletonComponent
|
||||||
*/
|
|
||||||
object Separators {
|
@Module
|
||||||
const val COMMA = ','
|
@InstallIn(SingletonComponent::class)
|
||||||
const val SEMICOLON = ';'
|
interface DeviceModule {
|
||||||
const val SLASH = '/'
|
@Binds fun deviceLibraryProvider(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
|
||||||
const val PLUS = '+'
|
|
||||||
const val AND = '&'
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 Auxio Project
|
* 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
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -16,26 +16,23 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.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.R
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.metadata.Date
|
import org.oxycblt.auxio.music.fs.MimeType
|
||||||
import org.oxycblt.auxio.music.metadata.Disc
|
import org.oxycblt.auxio.music.fs.Path
|
||||||
import org.oxycblt.auxio.music.metadata.ReleaseType
|
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.parseId3GenreNames
|
||||||
import org.oxycblt.auxio.music.metadata.parseMultiValue
|
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.nonZeroOrNull
|
||||||
import org.oxycblt.auxio.util.toUuidOrNull
|
import org.oxycblt.auxio.util.toUuidOrNull
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
import org.oxycblt.auxio.util.update
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Library-backed implementation of [Song].
|
* Library-backed implementation of [Song].
|
||||||
|
|
@ -44,7 +41,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
* @param musicSettings [MusicSettings] to for user parsing configuration.
|
* @param musicSettings [MusicSettings] to for user parsing configuration.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
|
class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Song {
|
||||||
override val uid =
|
override val uid =
|
||||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||||
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) }
|
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) }
|
||||||
|
|
@ -62,10 +59,11 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
|
||||||
update(rawSong.artistNames)
|
update(rawSong.artistNames)
|
||||||
update(rawSong.albumArtistNames)
|
update(rawSong.albumArtistNames)
|
||||||
}
|
}
|
||||||
override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" }
|
override val name =
|
||||||
override val rawSortName = rawSong.sortName
|
Name.Known.from(
|
||||||
override val sortName = SortName((rawSortName ?: rawName), musicSettings)
|
requireNotNull(rawSong.name) { "Invalid raw: No title" },
|
||||||
override fun resolveName(context: Context) = rawName
|
rawSong.sortName,
|
||||||
|
musicSettings)
|
||||||
|
|
||||||
override val track = rawSong.track
|
override val track = rawSong.track
|
||||||
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
|
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
|
override val album: Album
|
||||||
get() = unlikelyToBeNull(_album)
|
get() = unlikelyToBeNull(_album)
|
||||||
|
|
||||||
// Note: Only compare by UID so songs that differ only in MBID are treated differently.
|
override fun hashCode() = 31 * uid.hashCode() + rawSong.hashCode()
|
||||||
override fun hashCode() = uid.hashCode()
|
override fun equals(other: Any?) =
|
||||||
override fun equals(other: Any?) = other is Song && uid == other.uid
|
other is SongImpl && uid == other.uid && rawSong == other.rawSong
|
||||||
|
|
||||||
private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
|
private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
|
||||||
private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
|
private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
|
||||||
|
|
@ -238,10 +236,7 @@ class AlbumImpl(
|
||||||
update(rawAlbum.name)
|
update(rawAlbum.name)
|
||||||
update(rawAlbum.rawArtists.map { it.name })
|
update(rawAlbum.rawArtists.map { it.name })
|
||||||
}
|
}
|
||||||
override val rawName = rawAlbum.name
|
override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings)
|
||||||
override val rawSortName = rawAlbum.sortName
|
|
||||||
override val sortName = SortName((rawSortName ?: rawName), musicSettings)
|
|
||||||
override fun resolveName(context: Context) = rawName
|
|
||||||
|
|
||||||
override val dates = Date.Range.from(songs.mapNotNull { it.date })
|
override val dates = Date.Range.from(songs.mapNotNull { it.date })
|
||||||
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
|
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
|
||||||
|
|
@ -249,11 +244,15 @@ class AlbumImpl(
|
||||||
override val durationMs: Long
|
override val durationMs: Long
|
||||||
override val dateAdded: Long
|
override val dateAdded: Long
|
||||||
|
|
||||||
// Note: Append song contents to MusicParent equality so that Groups with
|
override fun hashCode(): Int {
|
||||||
// the same UID but different contents are not equal.
|
var hashCode = uid.hashCode()
|
||||||
override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
|
hashCode = 31 * hashCode + rawAlbum.hashCode()
|
||||||
|
hashCode = 31 * hashCode + songs.hashCode()
|
||||||
|
return hashCode
|
||||||
|
}
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
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>()
|
private val _artists = mutableListOf<ArtistImpl>()
|
||||||
override val artists: List<Artist>
|
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.
|
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||||
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
|
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
|
||||||
?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) }
|
?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) }
|
||||||
override val rawName = rawArtist.name
|
override val name =
|
||||||
override val rawSortName = rawArtist.sortName
|
rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) }
|
||||||
override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) }
|
?: Name.Unknown(R.string.def_artist)
|
||||||
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
|
|
||||||
override val songs: List<Song>
|
|
||||||
|
|
||||||
|
override val songs: List<Song>
|
||||||
override val albums: List<Album>
|
override val albums: List<Album>
|
||||||
override val durationMs: Long?
|
override val durationMs: Long?
|
||||||
override val isCollaborator: Boolean
|
override val isCollaborator: Boolean
|
||||||
|
|
||||||
// Note: Append song contents to MusicParent equality so that Groups with
|
// Note: Append song contents to MusicParent equality so that artists with
|
||||||
// the same UID but different contents are not equal.
|
// the same UID but different songs are not equal.
|
||||||
override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
|
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?) =
|
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>
|
override lateinit var genres: List<Genre>
|
||||||
|
|
||||||
|
|
@ -416,20 +423,23 @@ class GenreImpl(
|
||||||
override val songs: List<SongImpl>
|
override val songs: List<SongImpl>
|
||||||
) : Genre {
|
) : Genre {
|
||||||
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
|
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
|
||||||
override val rawName = rawGenre.name
|
override val name =
|
||||||
override val rawSortName = rawName
|
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
|
||||||
override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) }
|
?: Name.Unknown(R.string.def_genre)
|
||||||
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
|
|
||||||
|
|
||||||
override val albums: List<Album>
|
override val albums: List<Album>
|
||||||
override val artists: List<Artist>
|
override val artists: List<Artist>
|
||||||
override val durationMs: Long
|
override val durationMs: Long
|
||||||
|
|
||||||
// Note: Append song contents to MusicParent equality so that Groups with
|
override fun hashCode(): Int {
|
||||||
// the same UID but different contents are not equal.
|
var hashCode = uid.hashCode()
|
||||||
override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
|
hashCode = 31 * hashCode + rawGenre.hashCode()
|
||||||
|
hashCode = 31 * hashCode + songs.hashCode()
|
||||||
|
return hashCode
|
||||||
|
}
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
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 {
|
init {
|
||||||
val distinctAlbums = mutableSetOf<Album>()
|
val distinctAlbums = mutableSetOf<Album>()
|
||||||
|
|
@ -467,60 +477,8 @@ class GenreImpl(
|
||||||
*
|
*
|
||||||
* @return This instance upcasted to [Genre].
|
* @return This instance upcasted to [Genre].
|
||||||
*/
|
*/
|
||||||
fun finalize(): Music {
|
fun finalize(): Genre {
|
||||||
check(songs.isNotEmpty()) { "Malformed genre: Empty" }
|
check(songs.isNotEmpty()) { "Malformed genre: Empty" }
|
||||||
return this
|
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/>.
|
* 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 java.util.UUID
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.metadata.*
|
import org.oxycblt.auxio.music.fs.Directory
|
||||||
import org.oxycblt.auxio.music.storage.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.
|
* Raw information about a [SongImpl] obtained from the filesystem/Extractor instances.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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
|
* 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.
|
* unstable and should only be used for accessing the audio file.
|
||||||
|
|
@ -50,15 +51,15 @@ class RawSong(
|
||||||
var extensionMimeType: String? = null,
|
var extensionMimeType: String? = null,
|
||||||
/** @see Music.UID */
|
/** @see Music.UID */
|
||||||
var musicBrainzId: String? = null,
|
var musicBrainzId: String? = null,
|
||||||
/** @see Music.rawName */
|
/** @see Music.name */
|
||||||
var name: String? = null,
|
var name: String? = null,
|
||||||
/** @see Music.rawSortName */
|
/** @see Music.name */
|
||||||
var sortName: String? = null,
|
var sortName: String? = null,
|
||||||
/** @see Song.track */
|
/** @see Song.track */
|
||||||
var track: Int? = null,
|
var track: Int? = null,
|
||||||
/** @see Disc.number */
|
/** @see Song.disc */
|
||||||
var disc: Int? = null,
|
var disc: Int? = null,
|
||||||
/** @See Disc.name */
|
/** @See Song.disc */
|
||||||
var subtitle: String? = null,
|
var subtitle: String? = null,
|
||||||
/** @see Song.date */
|
/** @see Song.date */
|
||||||
var date: Date? = null,
|
var date: Date? = null,
|
||||||
|
|
@ -93,7 +94,7 @@ class RawSong(
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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
|
* 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.
|
* unstable and should only be used for accessing the system-provided cover art.
|
||||||
|
|
@ -101,9 +102,9 @@ class RawAlbum(
|
||||||
val mediaStoreId: Long,
|
val mediaStoreId: Long,
|
||||||
/** @see Music.uid */
|
/** @see Music.uid */
|
||||||
val musicBrainzId: UUID?,
|
val musicBrainzId: UUID?,
|
||||||
/** @see Music.rawName */
|
/** @see Music.name */
|
||||||
val name: String,
|
val name: String,
|
||||||
/** @see Music.rawSortName */
|
/** @see Music.name */
|
||||||
val sortName: String?,
|
val sortName: String?,
|
||||||
/** @see Album.releaseType */
|
/** @see Album.releaseType */
|
||||||
val releaseType: ReleaseType?,
|
val releaseType: ReleaseType?,
|
||||||
|
|
@ -140,12 +141,12 @@ class RawAlbum(
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class RawArtist(
|
data class RawArtist(
|
||||||
/** @see Music.UID */
|
/** @see Music.UID */
|
||||||
val musicBrainzId: UUID? = null,
|
val musicBrainzId: UUID? = null,
|
||||||
/** @see Music.rawName */
|
/** @see Music.name */
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
/** @see Music.rawSortName */
|
/** @see Music.name */
|
||||||
val sortName: String? = null
|
val sortName: String? = null
|
||||||
) {
|
) {
|
||||||
// Artists are grouped as follows:
|
// Artists are grouped as follows:
|
||||||
|
|
@ -182,17 +183,17 @@ class RawArtist(
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class RawGenre(
|
data class RawGenre(
|
||||||
/** @see Music.rawName */
|
/** @see Music.name */
|
||||||
val name: String? = null
|
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.
|
// Cache the hashCode for HashMap efficiency.
|
||||||
private val hashCode = name?.lowercase().hashCode()
|
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 hashCode() = hashCode
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
override fun equals(other: Any?) =
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.storage
|
package org.oxycblt.auxio.music.fs
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 Auxio Project
|
* 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
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.storage
|
package org.oxycblt.auxio.music.fs
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 Auxio Project
|
* 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
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.storage
|
package org.oxycblt.auxio.music.fs
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|
@ -28,7 +28,7 @@ import org.oxycblt.auxio.music.MusicSettings
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
class StorageModule {
|
class FsModule {
|
||||||
@Provides
|
@Provides
|
||||||
fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) =
|
fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) =
|
||||||
MediaStoreExtractor.from(context, musicSettings)
|
MediaStoreExtractor.from(context, musicSettings)
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.storage
|
package org.oxycblt.auxio.music.fs
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
|
@ -31,10 +31,10 @@ import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.music.MusicSettings
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
import org.oxycblt.auxio.music.cache.Cache
|
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.parseId3v2PositionField
|
||||||
import org.oxycblt.auxio.music.metadata.transformPositionField
|
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.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
|
|
@ -178,8 +178,8 @@ private abstract class BaseMediaStoreExtractor(
|
||||||
|
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
// Assume that a song can't inhabit multiple genre entries, as I
|
// Assume that a song can't inhabit multiple genre entries, as I
|
||||||
// doubt
|
// doubt MediaStore is actually aware that songs can have multiple
|
||||||
// MediaStore is actually aware that songs can have multiple genres.
|
// genres.
|
||||||
genreNamesMap[cursor.getLong(songIdIndex)] = name
|
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
|
// Free the cursor and signal that no more incomplete songs will be produced by
|
||||||
// this extractor.
|
// this extractor.
|
||||||
query.close()
|
query.close()
|
||||||
incompleteSongs.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -311,9 +310,8 @@ private abstract class BaseMediaStoreExtractor(
|
||||||
rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from)
|
rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from)
|
||||||
// A non-existent album name should theoretically be the name of the folder it contained
|
// 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
|
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it
|
||||||
// the
|
// the file is not actually in the root internal storage directory. We can't do
|
||||||
// file is not actually in the root internal storage directory. We can't do anything to
|
// anything to fix this, really.
|
||||||
// fix this, really.
|
|
||||||
rawSong.albumName = cursor.getString(albumIndex)
|
rawSong.albumName = cursor.getString(albumIndex)
|
||||||
// Android does not make a non-existent artist tag null, it instead fills it in
|
// 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
|
// 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
|
// 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.
|
// 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) :
|
private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
|
||||||
BaseMediaStoreExtractor(context, musicSettings) {
|
BaseMediaStoreExtractor(context, musicSettings) {
|
||||||
override val projection: Array<String>
|
override val projection: Array<String>
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.storage
|
package org.oxycblt.auxio.music.fs
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.storage
|
package org.oxycblt.auxio.music.fs
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.metadata
|
package org.oxycblt.auxio.music.info
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import java.text.ParseException
|
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
|
* A [Regex] that can parse a variable-precision ISO-8601 timestamp. Derived from
|
||||||
* https://github.com/quodlibet/mutagen
|
* https://github.com/quodlibet/mutagen
|
||||||
*/
|
*/
|
||||||
private val ISO8601_REGEX =
|
private val ISO8601_REGEX by lazy {
|
||||||
Regex(
|
Regex(
|
||||||
"""^(\d{4})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""")
|
"""^(\d{4})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a [Date] from a year component.
|
* Create a [Date] from a year component.
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.metadata
|
package org.oxycblt.auxio.music.info
|
||||||
|
|
||||||
import org.oxycblt.auxio.list.Item
|
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.
|
* @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> {
|
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 equals(other: Any?) = other is Disc && number == other.number
|
||||||
|
override fun hashCode() = number.hashCode()
|
||||||
override fun compareTo(other: Disc) = number.compareTo(other.number)
|
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/>.
|
* 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
|
import org.oxycblt.auxio.R
|
||||||
|
|
||||||
|
|
@ -28,15 +28,15 @@ import org.oxycblt.auxio.R
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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
|
* A specification of what kind of performance this release is. If null, the release is
|
||||||
* considered "Plain".
|
* 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. */
|
/** 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.
|
* 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
|
* @param refinement A specification of what kind of performance this release is. If null, the
|
||||||
* release is considered "Plain".
|
* release is considered "Plain".
|
||||||
*/
|
*/
|
||||||
data class Album(override val refinement: Refinement?) : ReleaseType() {
|
data class Album(override val refinement: Refinement?) : ReleaseType {
|
||||||
override val stringRes: Int
|
override val stringRes: Int
|
||||||
get() =
|
get() =
|
||||||
when (refinement) {
|
when (refinement) {
|
||||||
|
|
@ -61,7 +61,7 @@ sealed class ReleaseType {
|
||||||
* @param refinement A specification of what kind of performance this release is. If null, the
|
* @param refinement A specification of what kind of performance this release is. If null, the
|
||||||
* release is considered "Plain".
|
* release is considered "Plain".
|
||||||
*/
|
*/
|
||||||
data class EP(override val refinement: Refinement?) : ReleaseType() {
|
data class EP(override val refinement: Refinement?) : ReleaseType {
|
||||||
override val stringRes: Int
|
override val stringRes: Int
|
||||||
get() =
|
get() =
|
||||||
when (refinement) {
|
when (refinement) {
|
||||||
|
|
@ -78,7 +78,7 @@ sealed class ReleaseType {
|
||||||
* @param refinement A specification of what kind of performance this release is. If null, the
|
* @param refinement A specification of what kind of performance this release is. If null, the
|
||||||
* release is considered "Plain".
|
* release is considered "Plain".
|
||||||
*/
|
*/
|
||||||
data class Single(override val refinement: Refinement?) : ReleaseType() {
|
data class Single(override val refinement: Refinement?) : ReleaseType {
|
||||||
override val stringRes: Int
|
override val stringRes: Int
|
||||||
get() =
|
get() =
|
||||||
when (refinement) {
|
when (refinement) {
|
||||||
|
|
@ -95,7 +95,7 @@ sealed class ReleaseType {
|
||||||
* @param refinement A specification of what kind of performance this release is. If null, the
|
* @param refinement A specification of what kind of performance this release is. If null, the
|
||||||
* release is considered "Plain".
|
* release is considered "Plain".
|
||||||
*/
|
*/
|
||||||
data class Compilation(override val refinement: Refinement?) : ReleaseType() {
|
data class Compilation(override val refinement: Refinement?) : ReleaseType {
|
||||||
override val stringRes: Int
|
override val stringRes: Int
|
||||||
get() =
|
get() =
|
||||||
when (refinement) {
|
when (refinement) {
|
||||||
|
|
@ -110,7 +110,7 @@ sealed class ReleaseType {
|
||||||
* A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually
|
* A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually
|
||||||
* visual) media.
|
* visual) media.
|
||||||
*/
|
*/
|
||||||
object Soundtrack : ReleaseType() {
|
object Soundtrack : ReleaseType {
|
||||||
override val refinement: Refinement?
|
override val refinement: Refinement?
|
||||||
get() = null
|
get() = null
|
||||||
|
|
||||||
|
|
@ -122,7 +122,7 @@ sealed class ReleaseType {
|
||||||
* A (DJ) Mix. These are usually one large track consisting of the artist playing several
|
* A (DJ) Mix. These are usually one large track consisting of the artist playing several
|
||||||
* sub-tracks with smooth transitions between them.
|
* sub-tracks with smooth transitions between them.
|
||||||
*/
|
*/
|
||||||
object Mix : ReleaseType() {
|
object Mix : ReleaseType {
|
||||||
override val refinement: Refinement?
|
override val refinement: Refinement?
|
||||||
get() = null
|
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
|
* A Mix-tape. These are usually [EP]-sized releases of music made to promote an Artist or a
|
||||||
* future release.
|
* future release.
|
||||||
*/
|
*/
|
||||||
object Mixtape : ReleaseType() {
|
object Mixtape : ReleaseType {
|
||||||
override val refinement: Refinement?
|
override val refinement: Refinement?
|
||||||
get() = null
|
get() = null
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 Auxio Project
|
* 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
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -24,7 +24,7 @@ import android.media.MediaFormat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.music.Song
|
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.logD
|
||||||
import org.oxycblt.auxio.util.logE
|
import org.oxycblt.auxio.util.logE
|
||||||
import org.oxycblt.auxio.util.logW
|
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.
|
* @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
data class AudioInfo(
|
data class AudioProperties(
|
||||||
val bitrateKbps: Int?,
|
val bitrateKbps: Int?,
|
||||||
val sampleRateHz: Int?,
|
val sampleRateHz: Int?,
|
||||||
val resolvedMimeType: MimeType
|
val resolvedMimeType: MimeType
|
||||||
) {
|
) {
|
||||||
/** Implements the process of extracting [AudioInfo] from a given [Song]. */
|
/** Implements the process of extracting [AudioProperties] from a given [Song]. */
|
||||||
interface Provider {
|
interface Factory {
|
||||||
/**
|
/**
|
||||||
* Extract the [AudioInfo] of a given [Song].
|
* Extract the [AudioProperties] of a given [Song].
|
||||||
*
|
*
|
||||||
* @param song The [Song] to read.
|
* @param song The [Song] to read.
|
||||||
* @return The [AudioInfo] of the [Song], if possible to obtain.
|
* @return The [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.
|
* @param context [Context] required to read audio files.
|
||||||
*/
|
*/
|
||||||
class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
class AudioPropertiesFactoryImpl
|
||||||
AudioInfo.Provider {
|
@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
|
// 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
|
// 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.
|
// 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.
|
// that we can show.
|
||||||
logW("Unable to extract song attributes.")
|
logW("Unable to extract song attributes.")
|
||||||
logW(e.stackTraceToString())
|
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
|
// 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()
|
extractor.release()
|
||||||
|
|
||||||
return AudioInfo(bitrate, sampleRate, resolvedMimeType)
|
return AudioProperties(bitrate, sampleRate, resolvedMimeType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -26,7 +26,7 @@ import dagger.hilt.components.SingletonComponent
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface MetadataModule {
|
interface MetadataModule {
|
||||||
@Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor
|
@Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor
|
||||||
@Binds fun tagWorkerFactory(taskFactory: TagWorkerImpl.Factory): TagWorker.Factory
|
@Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory
|
||||||
@Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider
|
@Binds fun audioPropertiesFactory(factory: AudioPropertiesFactoryImpl): AudioProperties.Factory
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,14 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
||||||
return separators
|
return separators
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private object Separators {
|
||||||
|
const val COMMA = ','
|
||||||
|
const val SEMICOLON = ';'
|
||||||
|
const val SLASH = '/'
|
||||||
|
const val PLUS = '+'
|
||||||
|
const val AND = '&'
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS"
|
const val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.metadata
|
package org.oxycblt.auxio.music.metadata
|
||||||
|
|
||||||
import com.google.android.exoplayer2.MetadataRetriever
|
import androidx.media3.exoplayer.MetadataRetriever
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.music.model.RawSong
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
|
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
|
||||||
|
|
@ -87,8 +87,6 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} while (ongoingTasks)
|
} while (ongoingTasks)
|
||||||
|
|
||||||
completeSongs.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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:
|
* A [Regex] that implements parsing for ID3v2's genre format. Derived from mutagen:
|
||||||
* https://github.com/quodlibet/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
|
* 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
|
// Case 1: Genre IDs in the format (INT|RX|CR). If these exist, parse them as
|
||||||
// ID3v1 tags.
|
// ID3v1 tags.
|
||||||
val genreIds = groups.getOrNull(1)
|
val genreIds = groups.getOrNull(1)
|
||||||
if (genreIds != null && genreIds.isNotEmpty()) {
|
if (!genreIds.isNullOrEmpty()) {
|
||||||
val ids = genreIds.substring(1, genreIds.lastIndex).split(")(")
|
val ids = genreIds.substring(1, genreIds.lastIndex).split(")(")
|
||||||
for (id in ids) {
|
for (id in ids) {
|
||||||
id.parseId3v1Genre()?.let(genres::add)
|
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
|
// Case 2: Genre names as a normal string. The only case we have to look out for are
|
||||||
// escaped strings formatted as ((genre).
|
// escaped strings formatted as ((genre).
|
||||||
val genreName = groups.getOrNull(3)
|
val genreName = groups.getOrNull(3)
|
||||||
if (genreName != null && genreName.isNotEmpty()) {
|
if (!genreName.isNullOrEmpty()) {
|
||||||
if (genreName.startsWith("((")) {
|
if (genreName.startsWith("((")) {
|
||||||
genres.add(genreName.substring(1))
|
genres.add(genreName.substring(1))
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,15 @@
|
||||||
package org.oxycblt.auxio.music.metadata
|
package org.oxycblt.auxio.music.metadata
|
||||||
|
|
||||||
import androidx.core.text.isDigitsOnly
|
import androidx.core.text.isDigitsOnly
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import com.google.android.exoplayer2.MetadataRetriever
|
import androidx.media3.exoplayer.MetadataRetriever
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
import androidx.media3.exoplayer.source.TrackGroupArray
|
||||||
import java.util.concurrent.Future
|
import java.util.concurrent.Future
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.music.model.RawSong
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
import org.oxycblt.auxio.music.storage.toAudioUri
|
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.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
|
|
@ -56,14 +57,26 @@ interface TagWorker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class TagWorkerImpl
|
class TagWorkerFactoryImpl
|
||||||
private constructor(private val rawSong: RawSong, private val future: Future<TrackGroupArray>) :
|
@Inject
|
||||||
TagWorker {
|
constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory {
|
||||||
/**
|
override fun create(rawSong: RawSong): TagWorker =
|
||||||
* Try to get a completed song from this [TagWorker], if it has finished processing.
|
// 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
|
||||||
* @return A [RawSong] instance if processing has completed, null otherwise.
|
// 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? {
|
override fun poll(): RawSong? {
|
||||||
if (!future.isDone) {
|
if (!future.isDone) {
|
||||||
// Not done yet, nothing to do.
|
// Not done yet, nothing to do.
|
||||||
|
|
@ -95,12 +108,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
|
||||||
return rawSong
|
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>>) {
|
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
|
||||||
// Song
|
// Song
|
||||||
textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
|
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
|
// 3. ID3v2.4 Release Date, as it is the second most common date type
|
||||||
// 4. ID3v2.3 Original Date, as it is like #1
|
// 4. ID3v2.3 Original Date, as it is like #1
|
||||||
// 5. ID3v2.3 Release Year, as it is the most common date type
|
// 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["TDOR"]?.run { Date.from(first()) }
|
||||||
?: textFrames["TDRC"]?.run { Date.from(first()) }
|
?: textFrames["TDRC"]?.run { Date.from(first()) }
|
||||||
?: textFrames["TDRL"]?.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["TCMP"]
|
||||||
?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"])
|
?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"])
|
||||||
?.let {
|
?.let {
|
||||||
|
// Ignore invalid instances of this tag
|
||||||
if (it.size != 1 || it[0] != "1") return@let
|
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 =
|
||||||
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
|
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
|
||||||
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
|
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? {
|
private fun parseId3v23Date(textFrames: Map<String, List<String>>): Date? {
|
||||||
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
||||||
// is present.
|
// 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>>) {
|
private fun populateWithVorbis(comments: Map<String, List<String>>) {
|
||||||
// Song
|
// Song
|
||||||
comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
|
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
|
// Compilation Flag
|
||||||
(comments["compilation"] ?: comments["itunescompilation"])?.let {
|
(comments["compilation"] ?: comments["itunescompilation"])?.let {
|
||||||
|
// Ignore invalid instances of this tag
|
||||||
if (it.size != 1 || it[0] != "1") return@let
|
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 =
|
||||||
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
|
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
|
||||||
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES }
|
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 {
|
private companion object {
|
||||||
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
|
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
|
||||||
val COMPILATION_RELEASE_TYPES = listOf("compilation")
|
val COMPILATION_RELEASE_TYPES = listOf("compilation")
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.metadata
|
package org.oxycblt.auxio.music.metadata
|
||||||
|
|
||||||
import com.google.android.exoplayer2.metadata.Metadata
|
import androidx.media3.common.Metadata
|
||||||
import com.google.android.exoplayer2.metadata.id3.InternalFrame
|
import androidx.media3.extractor.metadata.id3.InternalFrame
|
||||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
import androidx.media3.extractor.metadata.id3.TextInformationFrame
|
||||||
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
import androidx.media3.extractor.metadata.vorbis.VorbisComment
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processing wrapper for [Metadata] that allows organized access to text-based audio tags.
|
* Processing wrapper for [Metadata] that allows organized access to text-based audio tags.
|
||||||
|
|
|
||||||
|
|
@ -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