diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 8738c44dd..f106a3aac 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -15,10 +15,10 @@ jobs:
uses: actions/checkout@v3
- name: Clone submodules
run: git submodule update --init --recursive
- - name: Set up JDK 11
+ - name: Set up JDK 17
uses: actions/setup-java@v3
with:
- java-version: '11'
+ java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
diff --git a/.gitmodules b/.gitmodules
index e806f30bf..552a758f6 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,4 +1,3 @@
-[submodule "ExoPlayer"]
- path = ExoPlayer
- url = https://github.com/OxygenCobalt/ExoPlayer.git
- branch = auxio
+[submodule "media"]
+ path = media
+ url = https://github.com/OxygenCobalt/media.git
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 937eb640e..63626d664 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,27 @@
# Changelog
+## 3.1.0
+
+#### What's New
+- Added playlist functionality
+
+#### What's Improved
+- Sorting now handles numbers of arbitrary length
+- Punctuation is now ignored in sorting with intelligent sort names disabled
+
+#### What's Fixed
+- Fixed issue where vorbis comments in the form of `metadata_block_picture` (lowercase) would not
+be parsed as images
+- Fixed issue where searches would match song file names case-sensitively
+- Fixed issue where the notification would not respond to changes in the album cover setting
+- Fixed issue where short names starting with an article would not be correctly sorted (ex. "the 1")
+- Fixed incorrect item arrangement on landscape
+- Fixed disappearing dividers in search view
+- Reduced likelihood that images (eg. album covers) would not update when the music library changed
+
+#### Dev/Meta
+- Switched to androidx media3 (New Home of ExoPlayer) for backing player components
+
## 3.0.5
#### What's Fixed
@@ -11,23 +33,23 @@ screen
## 3.0.4
#### What's New
-- Added support for `COMPILATION` and `ITUNESCOMPILATION` flags.
+- Added support for `COMPILATION` and `ITUNESCOMPILATION` flags
#### What's Improved
- Accept `REPLAYGAIN_*` adjustment information on OPUS files alongside
`R128_*` adjustments
- List updates are now consistent across the app
- Fixed jarring header update in detail view
-- Search view now trims search queries
+- Searching now ignores punctuation and trailing whitespace
- Audio effect (equalizer) session is now broadcast when playing/pausing
rather than on start/stop
-- Searching now ignores punctuation
- Numeric names are now logically sorted (i.e 7 before 15)
#### What's Fixed
- Fixed MP4-AAC files not playing due to an accidental audio extractor
deletion
- Fix "format" not appearing in song properties view
+- Fix visual bugs when editing duplicate songs in the queue
#### What's Changed
- "Ignore articles when sorting" is now "Intelligent sorting"
diff --git a/ExoPlayer b/ExoPlayer
deleted file mode 160000
index fef2bb3af..000000000
--- a/ExoPlayer
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit fef2bb3af622f235d98549ffe2efd8f7f7d2aa41
diff --git a/README.md b/README.md
index ceb879047..fb8ede52d 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
Auxio
A simple, rational music player for android.
-
-
+
+
@@ -21,7 +21,7 @@
## About
-Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of Exoplayer, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
+Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of [ExoPlayer](https://exoplayer.dev/), Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
I primarily built Auxio for myself, but you can use it too, I guess.
@@ -42,7 +42,7 @@ I primarily built Auxio for myself, but you can use it too, I guess.
## Features
-- [ExoPlayer](https://exoplayer.dev/) based playback
+- [ExoPlayer](https://exoplayer.dev/)-based playback
- Snappy UI derived from the latest Material Design guidelines
- Opinionated UX that prioritizes ease of use over edge cases
- Customizable behavior
@@ -50,7 +50,8 @@ I primarily built Auxio for myself, but you can use it too, I guess.
precise/original dates, sort tags, and more
- Advanced artist system that unifies artists and album artists
- SD Card-aware folder management
-- Reliable playback state persistence
+- Reliable playlisting functionality
+- Playback state persistence
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
- External equalizer support (ex. Wavelet)
- Edge-to-edge
diff --git a/app/build.gradle b/app/build.gradle
index 7ae1e5b25..4f968216b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -20,8 +20,8 @@ android {
defaultConfig {
applicationId namespace
- versionName "3.0.5"
- versionCode 29
+ versionName "3.1.0"
+ versionCode 30
minSdk 21
targetSdk 33
@@ -30,12 +30,12 @@ android {
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_11
- targetCompatibility JavaVersion.VERSION_11
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "11"
+ jvmTarget = "17"
freeCompilerArgs += "-Xjvm-default=all"
}
@@ -56,15 +56,16 @@ android {
}
}
}
-
packagingOptions {
- exclude "DebugProbesKt.bin"
- exclude "kotlin-tooling-metadata.json"
- exclude "**/kotlin/**"
- exclude "**/okhttp3/**"
- exclude "META-INF/*.version"
+ jniLibs {
+ excludes += ['**/kotlin/**', '**/okhttp3/**']
+ }
+ resources {
+ excludes += ['DebugProbesKt.bin', 'kotlin-tooling-metadata.json', '**/kotlin/**', '**/okhttp3/**', 'META-INF/*.version']
+ }
}
+
buildFeatures {
viewBinding true
}
@@ -75,26 +76,27 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.6.4"
+
+ def coroutines_version = '1.7.1'
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
// --- SUPPORT ---
// General
- // 1.4.0 is used in order to avoid a ripple bug in material components
implementation "androidx.appcompat:appcompat:1.6.1"
- implementation "androidx.core:core-ktx:1.9.0"
- implementation "androidx.activity:activity-ktx:1.6.1"
- implementation "androidx.fragment:fragment-ktx:1.5.5"
+ implementation "androidx.core:core-ktx:1.10.1"
+ implementation "androidx.activity:activity-ktx:1.7.1"
+ implementation "androidx.fragment:fragment-ktx:1.5.7"
// UI
implementation "androidx.recyclerview:recyclerview:1.3.0"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
- implementation 'androidx.core:core-ktx:1.9.0'
+ implementation 'androidx.core:core-ktx:1.10.1'
// Lifecycle
- def lifecycle_version = "2.6.0"
+ def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@@ -111,7 +113,7 @@ dependencies {
implementation "androidx.preference:preference-ktx:1.2.0"
// Database
- def room_version = '2.5.0'
+ def room_version = '2.6.0-alpha01'
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
@@ -119,27 +121,27 @@ dependencies {
// --- THIRD PARTY ---
// Exoplayer (Vendored)
- implementation project(":exoplayer-library-core")
- implementation project(":exoplayer-extension-ffmpeg")
+ implementation project(":media-lib-exoplayer")
+ implementation project(":media-lib-decoder-ffmpeg")
// Image loading
- implementation 'io.coil-kt:coil-base:2.2.2'
+ implementation 'io.coil-kt:coil-base:2.3.0'
// Material
- // TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout can be worked around
+ // TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout is actually available
+ // in a version that I can build with
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
// PR a fix.
implementation "com.google.android.material:material:1.8.0-alpha01"
// Dependency Injection
- def dagger_version = '2.45'
- implementation "com.google.dagger:dagger:$dagger_version"
- kapt "com.google.dagger:dagger-compiler:$dagger_version"
+ implementation "com.google.dagger:dagger:$hilt_version"
+ kapt "com.google.dagger:dagger-compiler:$hilt_version"
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
// Testing
- debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
+ debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
testImplementation "junit:junit:4.13.2"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index b63d5e026..63c3c3a01 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -22,4 +22,15 @@
# Obsfucation is what proprietary software does to keep the user unaware of it's abuses.
# Also it's easier to fix issues if the stack trace symbols remain unmangled.
--dontobfuscate
\ No newline at end of file
+-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
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt
index 32c443adb..a0ba54a3d 100644
--- a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt
+++ b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt
@@ -35,6 +35,6 @@ class StubTest {
@Test
fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("org.oxycblt.auxio", appContext.packageName)
+ assertEquals("org.oxycblt.auxio.debug", appContext.packageName)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/Auxio.kt b/app/src/main/java/org/oxycblt/auxio/Auxio.kt
index 77eed0ff9..df737e4c2 100644
--- a/app/src/main/java/org/oxycblt/auxio/Auxio.kt
+++ b/app/src/main/java/org/oxycblt/auxio/Auxio.kt
@@ -25,6 +25,7 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
+import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.ui.UISettings
@@ -39,6 +40,7 @@ class Auxio : Application() {
@Inject lateinit var imageSettings: ImageSettings
@Inject lateinit var playbackSettings: PlaybackSettings
@Inject lateinit var uiSettings: UISettings
+ @Inject lateinit var homeSettings: HomeSettings
override fun onCreate() {
super.onCreate()
@@ -46,6 +48,7 @@ class Auxio : Application() {
imageSettings.migrate()
playbackSettings.migrate()
uiSettings.migrate()
+ homeSettings.migrate()
// Adding static shortcuts in a dynamic manner is better than declaring them
// manually, as it will properly handle the difference between debug and release
// Auxio instances.
diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
index d2d1e933f..d0bff5315 100644
--- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
+++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
@@ -33,18 +33,26 @@ object IntegerTable {
const val VIEW_TYPE_ARTIST = 0xA002
/** GenreViewHolder */
const val VIEW_TYPE_GENRE = 0xA003
+ /** PlaylistViewHolder */
+ const val VIEW_TYPE_PLAYLIST = 0xA004
/** BasicHeaderViewHolder */
- const val VIEW_TYPE_BASIC_HEADER = 0xA004
+ const val VIEW_TYPE_BASIC_HEADER = 0xA005
+ /** DividerViewHolder */
+ const val VIEW_TYPE_DIVIDER = 0xA006
/** SortHeaderViewHolder */
- const val VIEW_TYPE_SORT_HEADER = 0xA005
+ const val VIEW_TYPE_SORT_HEADER = 0xA007
/** AlbumSongViewHolder */
- const val VIEW_TYPE_ALBUM_SONG = 0xA007
+ const val VIEW_TYPE_ALBUM_SONG = 0xA008
/** ArtistAlbumViewHolder */
const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
/** ArtistSongViewHolder */
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** DiscHeaderViewHolder */
- const val VIEW_TYPE_DISC_HEADER = 0xA00C
+ const val VIEW_TYPE_DISC_HEADER = 0xA00B
+ /** EditHeaderViewHolder */
+ const val VIEW_TYPE_EDIT_HEADER = 0xA00C
+ /** PlaylistSongViewHolder */
+ const val VIEW_TYPE_PLAYLIST_SONG = 0xA00E
/** "Music playback" notification code */
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** "Music loading" notification code */
@@ -65,34 +73,36 @@ object IntegerTable {
const val PLAYBACK_MODE_IN_ALBUM = 0xA105
/** PlaybackMode.ALL_SONGS */
const val PLAYBACK_MODE_ALL_SONGS = 0xA106
- /** DisplayMode.NONE (No Longer used but still reserved) */
- // const val DISPLAY_MODE_NONE = 0xA107
- /** MusicMode._GENRES */
- const val MUSIC_MODE_GENRES = 0xA108
- /** MusicMode._ARTISTS */
- const val MUSIC_MODE_ARTISTS = 0xA109
- /** MusicMode._ALBUMS */
- const val MUSIC_MODE_ALBUMS = 0xA10A
/** MusicMode.SONGS */
const val MUSIC_MODE_SONGS = 0xA10B
- /** Sort.ByName */
+ /** MusicMode.ALBUMS */
+ const val MUSIC_MODE_ALBUMS = 0xA10A
+ /** MusicMode.ARTISTS */
+ const val MUSIC_MODE_ARTISTS = 0xA109
+ /** MusicMode.GENRES */
+ const val MUSIC_MODE_GENRES = 0xA108
+ /** MusicMode.PLAYLISTS */
+ const val MUSIC_MODE_PLAYLISTS = 0xA107
+ /** Sort.Mode.ByName */
const val SORT_BY_NAME = 0xA10C
- /** Sort.ByArtist */
+ /** Sort.Mode.ByArtist */
const val SORT_BY_ARTIST = 0xA10D
- /** Sort.ByAlbum */
+ /** Sort.Mode.ByAlbum */
const val SORT_BY_ALBUM = 0xA10E
- /** Sort.ByYear */
+ /** Sort.Mode.ByYear */
const val SORT_BY_YEAR = 0xA10F
- /** Sort.ByDuration */
+ /** Sort.Mode.ByDuration */
const val SORT_BY_DURATION = 0xA114
- /** Sort.ByCount */
+ /** Sort.Mode.ByCount */
const val SORT_BY_COUNT = 0xA115
- /** Sort.ByDisc */
+ /** Sort.Mode.ByDisc */
const val SORT_BY_DISC = 0xA116
- /** Sort.ByTrack */
+ /** Sort.Mode.ByTrack */
const val SORT_BY_TRACK = 0xA117
- /** Sort.ByDateAdded */
+ /** Sort.Mode.ByDateAdded */
const val SORT_BY_DATE_ADDED = 0xA118
+ /** Sort.Mode.None */
+ const val SORT_BY_NONE = 0xA11F
/** ReplayGainMode.Off (No longer used but still reserved) */
// const val REPLAY_GAIN_MODE_OFF = 0xA110
/** ReplayGainMode.Track */
diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
index 1fb9733f2..d29b513a6 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
@@ -48,6 +48,10 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
* TODO: Migrate to material animation system
* TODO: Unit testing
+ * TODO: Fix UID naming
+ * TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims)
+ * TODO: Add more logging
+ * TODO: Try to move on from synchronized and volatile in shared objs
*/
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
index 6c1913d5f..665fc7bdc 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
@@ -38,14 +38,17 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding
+import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Music
+import org.oxycblt.auxio.music.MusicViewModel
+import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.navigation.MainNavigationAction
+import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
-import org.oxycblt.auxio.ui.MainNavigationAction
-import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.*
@@ -60,9 +63,11 @@ class MainFragment :
ViewBindingFragment(),
ViewTreeObserver.OnPreDrawListener,
NavController.OnDestinationChangedListener {
- private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
+ private val musicModel: MusicViewModel by activityViewModels()
+ private val playbackModel: PlaybackViewModel by activityViewModels()
private val selectionModel: SelectionViewModel by activityViewModels()
+ private val detailModel: DetailViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback()
private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f
@@ -132,6 +137,10 @@ class MainFragment :
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
+ collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
+ collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist)
+ collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist)
+ collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
@@ -258,7 +267,7 @@ class MainFragment :
initialNavDestinationChange = true
return
}
- selectionModel.consume()
+ selectionModel.drop()
}
private fun handleMainNavigation(action: MainNavigationAction?) {
@@ -268,8 +277,8 @@ class MainFragment :
}
when (action) {
- is MainNavigationAction.Expand -> tryExpandSheets()
- is MainNavigationAction.Collapse -> tryCollapseSheets()
+ is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel()
+ is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel()
is MainNavigationAction.Directions ->
findNavController().navigateSafe(action.directions)
}
@@ -279,7 +288,7 @@ class MainFragment :
private fun handleExploreNavigation(item: Music?) {
if (item != null) {
- tryCollapseSheets()
+ tryClosePlaybackPanel()
}
}
@@ -300,6 +309,40 @@ class MainFragment :
}
}
+ private fun handleNewPlaylist(songs: List?) {
+ 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?) {
+ if (songs != null) {
+ findNavController()
+ .navigateSafe(
+ MainFragmentDirections.actionAddToPlaylist(songs.map { it.uid }.toTypedArray()))
+ musicModel.songsToAdd.consume()
+ }
+ }
+
private fun handlePlaybackArtistPicker(song: Song?) {
if (song != null) {
navModel.mainNavigateTo(
@@ -318,22 +361,33 @@ class MainFragment :
}
}
- private fun tryExpandSheets() {
+ private fun tryOpenPlaybackPanel() {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
+
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is not expanded and not hidden, we can expand it.
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
+ return
+ }
+
+ val queueSheetBehavior =
+ (binding.queueSheet.coordinatorLayoutBehavior ?: return) as QueueBottomSheetBehavior
+ if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
+ queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
+ // Queue sheet and playback sheet is expanded, close the queue sheet so the
+ // playback panel can eb shown.
+ queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
}
}
- private fun tryCollapseSheets() {
+ private fun tryClosePlaybackPanel() {
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
- // Make sure the queue is also collapsed here.
+ // Playback sheet (and possibly queue) needs to be collapsed.
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
@@ -406,8 +460,13 @@ class MainFragment :
return
}
+ // Clear out pending playlist edits.
+ if (detailModel.dropPlaylistEdit()) {
+ return
+ }
+
// Clear out any prior selections.
- if (selectionModel.consume().isNotEmpty()) {
+ if (selectionModel.drop()) {
return
}
@@ -435,6 +494,7 @@ class MainFragment :
isEnabled =
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
+ detailModel.editedPlaylist.value != null ||
selectionModel.selected.value.isNotEmpty() ||
exploreNavController.currentDestination?.id !=
exploreNavController.graph.startDestinationId
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
index 12f7098cf..d1f13160c 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
@@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
+import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
@@ -34,6 +35,8 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
+import org.oxycblt.auxio.list.Divider
+import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
@@ -43,9 +46,11 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
+import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.info.Disc
+import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
-import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
/**
@@ -61,6 +66,7 @@ class AlbumDetailFragment :
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what album to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an album.
@@ -87,17 +93,27 @@ class AlbumDetailFragment :
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --
- binding.detailToolbar.apply {
+ binding.detailNormalToolbar.apply {
inflateMenu(R.menu.menu_album_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@AlbumDetailFragment)
}
- binding.detailRecycler.adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
+ binding.detailRecycler.apply {
+ adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
+ (layoutManager as GridLayoutManager).setFullWidthLookup {
+ if (it != 0) {
+ val item = detailModel.albumList.value[it - 1]
+ item is Divider || item is Header || item is Disc
+ } else {
+ true
+ }
+ }
+ }
// -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
- detailModel.setAlbumUid(args.albumUid)
+ detailModel.setAlbum(args.albumUid)
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
collectImmediately(detailModel.albumList, ::updateList)
collectImmediately(
@@ -108,7 +124,7 @@ class AlbumDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
- binding.detailToolbar.setOnMenuItemClickListener(null)
+ binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
@@ -136,6 +152,10 @@ class AlbumDetailFragment :
onNavigateToParentArtist()
true
}
+ R.id.action_playlist_add -> {
+ musicModel.addToPlaylist(currentAlbum)
+ true
+ }
else -> false
}
}
@@ -159,8 +179,10 @@ class AlbumDetailFragment :
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_album_sort) {
+ // Select the corresponding sort mode option
val sort = detailModel.albumSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
+ // Select the corresponding sort direction option
val directionItemId =
when (sort.direction) {
Sort.Direction.ASCENDING -> R.id.option_sort_asc
@@ -171,8 +193,10 @@ class AlbumDetailFragment :
item.isChecked = !item.isChecked
detailModel.albumSongSort =
when (item.itemId) {
+ // Sort direction options
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
+ // Any other option is a sort mode
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
true
@@ -190,7 +214,7 @@ class AlbumDetailFragment :
findNavController().navigateUp()
return
}
- requireBinding().detailToolbar.title = album.resolveName(requireContext())
+ requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
albumHeaderAdapter.setParent(album)
}
@@ -289,6 +313,13 @@ class AlbumDetailFragment :
private fun updateSelection(selected: List) {
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)
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
index eecd5b5bc..4677aee62 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
@@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
+import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
@@ -34,6 +35,8 @@ import org.oxycblt.auxio.detail.header.ArtistDetailHeaderAdapter
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
+import org.oxycblt.auxio.list.Divider
+import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
@@ -42,9 +45,10 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
+import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
-import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
/**
@@ -60,6 +64,7 @@ class ArtistDetailFragment :
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what artist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an artist.
@@ -86,18 +91,31 @@ class ArtistDetailFragment :
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
- binding.detailToolbar.apply {
- inflateMenu(R.menu.menu_genre_artist_detail)
+ binding.detailNormalToolbar.apply {
+ inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@ArtistDetailFragment)
}
- binding.detailRecycler.adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
+ binding.detailRecycler.apply {
+ adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
+ (layoutManager as GridLayoutManager).setFullWidthLookup {
+ if (it != 0) {
+ val item =
+ detailModel.artistList.value.getOrElse(it - 1) {
+ return@setFullWidthLookup false
+ }
+ item is Divider || item is Header
+ } else {
+ true
+ }
+ }
+ }
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
- detailModel.setArtistUid(args.artistUid)
- collectImmediately(detailModel.currentArtist, ::updateItem)
+ detailModel.setArtist(args.artistUid)
+ collectImmediately(detailModel.currentArtist, ::updateArtist)
collectImmediately(detailModel.artistList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@@ -107,7 +125,7 @@ class ArtistDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
- binding.detailToolbar.setOnMenuItemClickListener(null)
+ binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
@@ -131,6 +149,10 @@ class ArtistDetailFragment :
requireContext().showToast(R.string.lng_queue_added)
true
}
+ R.id.action_playlist_add -> {
+ musicModel.addToPlaylist(currentArtist)
+ true
+ }
else -> false
}
}
@@ -171,8 +193,10 @@ class ArtistDetailFragment :
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_artist_sort) {
+ // Select the corresponding sort mode option
val sort = detailModel.artistSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
+ // Select the corresponding sort direction option
val directionItemId =
when (sort.direction) {
Sort.Direction.ASCENDING -> R.id.option_sort_asc
@@ -184,8 +208,10 @@ class ArtistDetailFragment :
detailModel.artistSongSort =
when (item.itemId) {
+ // Sort direction options
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
+ // Any other option is a sort mode
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
@@ -194,13 +220,13 @@ class ArtistDetailFragment :
}
}
- private fun updateItem(artist: Artist?) {
+ private fun updateArtist(artist: Artist?) {
if (artist == null) {
// Artist we were showing no longer exists.
findNavController().navigateUp()
return
}
- requireBinding().detailToolbar.title = artist.resolveName(requireContext())
+ requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
artistHeaderAdapter.setParent(artist)
}
@@ -260,6 +286,13 @@ class ArtistDetailFragment :
private fun updateSelection(selected: List) {
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)
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
index d486fa109..15b803ae6 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
@@ -44,9 +44,6 @@ import org.oxycblt.auxio.util.lazyReflectedField
* and thus scrolling past them should make the toolbar show the name in order to give context on
* where the user currently is.
*
- * This task should nominally be accomplished with CollapsingToolbarLayout, but I have not figured
- * out how to get that working sensibly yet.
- *
* @author Alexander Capehart (OxygenCobalt)
*/
class DetailAppBarLayout
@@ -72,7 +69,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
// used within the detail layouts.
- val toolbar = findViewById(R.id.detail_toolbar)
+ val toolbar = findViewById(R.id.detail_normal_toolbar)
// The Toolbar's title view is actually hidden. To avoid having to create our own
// title view, we just reflect into Toolbar and grab the hidden field.
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
index 20923abac..92052a955 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
@@ -30,16 +30,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
+import org.oxycblt.auxio.detail.list.EditHeader
import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader
+import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.music.metadata.AudioInfo
-import org.oxycblt.auxio.music.metadata.Disc
-import org.oxycblt.auxio.music.metadata.ReleaseType
-import org.oxycblt.auxio.music.model.Library
+import org.oxycblt.auxio.music.info.Disc
+import org.oxycblt.auxio.music.info.ReleaseType
+import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.*
@@ -54,22 +55,22 @@ class DetailViewModel
@Inject
constructor(
private val musicRepository: MusicRepository,
- private val audioInfoProvider: AudioInfo.Provider,
+ private val audioPropertiesFactory: AudioProperties.Factory,
private val musicSettings: MusicSettings,
private val playbackSettings: PlaybackSettings
-) : ViewModel(), MusicRepository.Listener {
- private var currentSongJob: Job? = null
-
+) : ViewModel(), MusicRepository.UpdateListener {
// --- SONG ---
+
+ private var currentSongJob: Job? = null
private val _currentSong = MutableStateFlow(null)
/** The current [Song] to display. Null if there is nothing to show. */
val currentSong: StateFlow
get() = _currentSong
- private val _songAudioInfo = MutableStateFlow(null)
- /** The [AudioInfo] of the currently shown [Song]. Null if not loaded yet. */
- val songAudioInfo: StateFlow = _songAudioInfo
+ private val _songAudioProperties = MutableStateFlow(null)
+ /** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
+ val songAudioProperties: StateFlow = _songAudioProperties
// --- ALBUM ---
@@ -144,6 +145,29 @@ constructor(
currentGenre.value?.let { refreshGenreList(it, true) }
}
+ // --- PLAYLIST ---
+
+ private val _currentPlaylist = MutableStateFlow(null)
+ /** The current [Playlist] to display. Null if there is nothing to do. */
+ val currentPlaylist: StateFlow
+ get() = _currentPlaylist
+
+ private val _playlistList = MutableStateFlow(listOf- ())
+ /** The current list data derived from [currentPlaylist] */
+ val playlistList: StateFlow
> = _playlistList
+ private val _playlistInstructions = MutableEvent()
+ /** Instructions for updating [playlistList] in the UI. */
+ val playlistInstructions: Event
+ get() = _playlistInstructions
+
+ private val _editedPlaylist = MutableStateFlow?>(null)
+ /**
+ * The new playlist songs created during the current editing session. Null if no editing session
+ * is occurring.
+ */
+ val editedPlaylist: StateFlow?>
+ get() = _editedPlaylist
+
/**
* The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently
* shown item.
@@ -152,126 +176,218 @@ constructor(
get() = playbackSettings.inParentPlaybackMode
init {
- musicRepository.addListener(this)
+ musicRepository.addUpdateListener(this)
}
override fun onCleared() {
- musicRepository.removeListener(this)
+ musicRepository.removeUpdateListener(this)
}
- override fun onLibraryChanged(library: Library?) {
- if (library == null) {
- // Nothing to do.
- return
- }
-
+ override fun onMusicChanges(changes: MusicRepository.Changes) {
// If we are showing any item right now, we will need to refresh it (and any information
// related to it) with the new library in order to prevent stale items from showing up
// in the UI.
+ val deviceLibrary = musicRepository.deviceLibrary
+ if (changes.deviceLibrary && deviceLibrary != null) {
+ val song = currentSong.value
+ if (song != null) {
+ _currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo)
+ logD("Updated song to ${currentSong.value}")
+ }
- val song = currentSong.value
- if (song != null) {
- _currentSong.value = library.sanitize(song)?.also(::refreshAudioInfo)
- logD("Updated song to ${currentSong.value}")
+ val album = currentAlbum.value
+ if (album != null) {
+ _currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
+ logD("Updated album to ${currentAlbum.value}")
+ }
+
+ val artist = currentArtist.value
+ if (artist != null) {
+ _currentArtist.value =
+ deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
+ logD("Updated artist to ${currentArtist.value}")
+ }
+
+ val genre = currentGenre.value
+ if (genre != null) {
+ _currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList)
+ logD("Updated genre to ${currentGenre.value}")
+ }
}
- val album = currentAlbum.value
- if (album != null) {
- _currentAlbum.value = library.sanitize(album)?.also(::refreshAlbumList)
- logD("Updated genre to ${currentAlbum.value}")
- }
-
- val artist = currentArtist.value
- if (artist != null) {
- _currentArtist.value = library.sanitize(artist)?.also(::refreshArtistList)
- logD("Updated genre to ${currentArtist.value}")
- }
-
- val genre = currentGenre.value
- if (genre != null) {
- _currentGenre.value = library.sanitize(genre)?.also(::refreshGenreList)
- logD("Updated genre to ${currentGenre.value}")
+ val userLibrary = musicRepository.userLibrary
+ if (changes.userLibrary && userLibrary != null) {
+ val playlist = currentPlaylist.value
+ if (playlist != null) {
+ logD("Updated playlist to ${currentPlaylist.value}")
+ _currentPlaylist.value =
+ userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
+ }
}
}
/**
- * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and
- * [songAudioInfo] will be updated to align with the new [Song].
+ * Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
+ * be updated to align with the new [Song].
*
* @param uid The UID of the [Song] to load. Must be valid.
*/
- fun setSongUid(uid: Music.UID) {
- if (_currentSong.value?.uid == uid) {
- // Nothing to do.
- return
- }
+ fun setSong(uid: Music.UID) {
logD("Opening Song [uid: $uid]")
- _currentSong.value = requireMusic(uid)?.also(::refreshAudioInfo)
+ _currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
}
/**
- * Set a new [currentAlbum] from it's [Music.UID]. If the [Music.UID] differs, [currentAlbum]
- * and [albumList] will be updated to align with the new [Album].
+ * Set a new [currentAlbum] from it's [Music.UID]. [currentAlbum] and [albumList] will be
+ * updated to align with the new [Album].
*
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
*/
- fun setAlbumUid(uid: Music.UID) {
- if (_currentAlbum.value?.uid == uid) {
- // Nothing to do.
- return
- }
+ fun setAlbum(uid: Music.UID) {
logD("Opening Album [uid: $uid]")
- _currentAlbum.value = requireMusic(uid)?.also(::refreshAlbumList)
+ _currentAlbum.value =
+ musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
}
/**
- * Set a new [currentArtist] from it's [Music.UID]. If the [Music.UID] differs, [currentArtist]
- * and [artistList] will be updated to align with the new [Artist].
+ * Set a new [currentArtist] from it's [Music.UID]. [currentArtist] and [artistList] will be
+ * updated to align with the new [Artist].
*
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
*/
- fun setArtistUid(uid: Music.UID) {
- if (_currentArtist.value?.uid == uid) {
- // Nothing to do.
- return
- }
+ fun setArtist(uid: Music.UID) {
logD("Opening Artist [uid: $uid]")
- _currentArtist.value = requireMusic(uid)?.also(::refreshArtistList)
+ _currentArtist.value =
+ musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
}
/**
- * Set a new [currentGenre] from it's [Music.UID]. If the [Music.UID] differs, [currentGenre]
- * and [genreList] will be updated to align with the new album.
+ * Set a new [currentGenre] from it's [Music.UID]. [currentGenre] and [genreList] will be
+ * updated to align with the new album.
*
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
*/
- fun setGenreUid(uid: Music.UID) {
- if (_currentGenre.value?.uid == uid) {
- // Nothing to do.
- return
- }
+ fun setGenre(uid: Music.UID) {
logD("Opening Genre [uid: $uid]")
- _currentGenre.value = requireMusic(uid)?.also(::refreshGenreList)
+ _currentGenre.value =
+ musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
}
- private fun requireMusic(uid: Music.UID) = musicRepository.library?.find(uid)
+ /**
+ * Set a new [currentPlaylist] from it's [Music.UID]. If the [Music.UID] differs,
+ * [currentPlaylist] and [currentPlaylist] will be updated to align with the new album.
+ *
+ * @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
+ */
+ fun setPlaylist(uid: Music.UID) {
+ logD("Opening Playlist [uid: $uid]")
+ _currentPlaylist.value =
+ musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
+ }
+
+ /** Start a playlist editing session. Does nothing if a playlist is not being shown. */
+ fun startPlaylistEdit() {
+ val playlist = _currentPlaylist.value ?: return
+ logD("Starting playlist edit")
+ _editedPlaylist.value = playlist.songs
+ refreshPlaylistList(playlist)
+ }
+
+ /**
+ * End a playlist editing session and commits it to the database. Does nothing if there was no
+ * prior editing session.
+ */
+ fun savePlaylistEdit() {
+ val playlist = _currentPlaylist.value ?: return
+ val editedPlaylist = _editedPlaylist.value ?: return
+ viewModelScope.launch {
+ musicRepository.rewritePlaylist(playlist, editedPlaylist)
+ // TODO: The user could probably press some kind of button if they were fast enough.
+ // Think of a better way to handle this state.
+ _editedPlaylist.value = null
+ }
+ }
+
+ /**
+ * End a playlist editing session and keep the prior state. Does nothing if there was no prior
+ * editing session.
+ *
+ * @return true if the session was ended, false otherwise.
+ */
+ fun dropPlaylistEdit(): Boolean {
+ val playlist = _currentPlaylist.value ?: return false
+ if (_editedPlaylist.value == null) {
+ // Nothing to do.
+ return false
+ }
+ _editedPlaylist.value = null
+ refreshPlaylistList(playlist)
+ return true
+ }
+
+ /**
+ * (Visually) move a song in the current playlist. Does nothing if not in an editing session.
+ *
+ * @param from The start position, in the list adapter data.
+ * @param to The destination position, in the list adapter data.
+ * @return true if the song was moved, false otherwise.
+ */
+ fun movePlaylistSongs(from: Int, to: Int): Boolean {
+ // TODO: Song re-sorting
+ val playlist = _currentPlaylist.value ?: return false
+ val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
+ val realFrom = from - 2
+ val realTo = to - 2
+ if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
+ return false
+ }
+ editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
+ _editedPlaylist.value = editedPlaylist
+ refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
+ return true
+ }
+
+ /**
+ * (Visually) remove a song in the current playlist. Does nothing if not in an editing session.
+ *
+ * @param at The position of the item to remove, in the list adapter data.
+ */
+ fun removePlaylistSong(at: Int) {
+ val playlist = _currentPlaylist.value ?: return
+ val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
+ val realAt = at - 2
+ if (realAt !in editedPlaylist.indices) {
+ return
+ }
+ editedPlaylist.removeAt(realAt)
+ _editedPlaylist.value = editedPlaylist
+ refreshPlaylistList(
+ playlist,
+ if (editedPlaylist.isNotEmpty()) {
+ UpdateInstructions.Remove(at, 1)
+ } else {
+ UpdateInstructions.Remove(at - 2, 3)
+ })
+ }
private fun refreshAudioInfo(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
- _songAudioInfo.value = null
+ _songAudioProperties.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
- val info = audioInfoProvider.extract(song)
+ val info = audioPropertiesFactory.extract(song)
yield()
- _songAudioInfo.value = info
+ _songAudioProperties.value = info
}
}
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
- logD("Refreshing album data")
+ logD("Refreshing album list")
val list = mutableListOf- ()
- list.add(SortHeader(R.string.lbl_songs))
+ val header = SortHeader(R.string.lbl_songs)
+ list.add(Divider(header))
+ list.add(header)
val instructions =
if (replace) {
// Intentional so that the header item isn't replaced with the songs
@@ -301,7 +417,7 @@ constructor(
}
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
- logD("Refreshing artist data")
+ logD("Refreshing artist list")
val list = mutableListOf
- ()
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
@@ -329,7 +445,9 @@ constructor(
logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
- list.add(BasicHeader(entry.key.headerTitleRes))
+ val header = BasicHeader(entry.key.headerTitleRes)
+ list.add(Divider(header))
+ list.add(header)
list.addAll(entry.value)
}
@@ -337,7 +455,9 @@ constructor(
var instructions: UpdateInstructions = UpdateInstructions.Diff
if (artist.songs.isNotEmpty()) {
logD("Songs present in this artist, adding header")
- list.add(SortHeader(R.string.lbl_songs))
+ val header = SortHeader(R.string.lbl_songs)
+ list.add(Divider(header))
+ list.add(header)
if (replace) {
// Intentional so that the header item isn't replaced with the songs
instructions = UpdateInstructions.Replace(list.size)
@@ -350,12 +470,17 @@ constructor(
}
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
- logD("Refreshing genre data")
+ logD("Refreshing genre list")
val list = mutableListOf
- ()
// Genre is guaranteed to always have artists and songs.
- list.add(BasicHeader(R.string.lbl_artists))
+ val artistHeader = BasicHeader(R.string.lbl_artists)
+ list.add(Divider(artistHeader))
+ list.add(artistHeader)
list.addAll(genre.artists)
- list.add(SortHeader(R.string.lbl_songs))
+
+ val songHeader = SortHeader(R.string.lbl_songs)
+ list.add(Divider(songHeader))
+ list.add(songHeader)
val instructions =
if (replace) {
// Intentional so that the header item isn't replaced with the songs
@@ -368,6 +493,25 @@ constructor(
_genreList.value = list
}
+ private fun refreshPlaylistList(
+ playlist: Playlist,
+ instructions: UpdateInstructions = UpdateInstructions.Diff
+ ) {
+ logD("Refreshing playlist list")
+ val list = mutableListOf
- ()
+
+ 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.
*
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
index 555d8549a..4ef67d581 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
@@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
+import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
@@ -34,18 +35,15 @@ import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
+import org.oxycblt.auxio.list.Divider
+import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionViewModel
-import org.oxycblt.auxio.music.Album
-import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.Genre
-import org.oxycblt.auxio.music.Music
-import org.oxycblt.auxio.music.MusicParent
-import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.*
+import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
-import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
/**
@@ -61,6 +59,7 @@ class GenreDetailFragment :
private val detailModel: DetailViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what genre to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an genre.
@@ -85,18 +84,31 @@ class GenreDetailFragment :
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
- binding.detailToolbar.apply {
- inflateMenu(R.menu.menu_genre_artist_detail)
+ binding.detailNormalToolbar.apply {
+ inflateMenu(R.menu.menu_parent_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@GenreDetailFragment)
}
- binding.detailRecycler.adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
+ binding.detailRecycler.apply {
+ adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
+ (layoutManager as GridLayoutManager).setFullWidthLookup {
+ if (it != 0) {
+ val item =
+ detailModel.genreList.value.getOrElse(it - 1) {
+ return@setFullWidthLookup false
+ }
+ item is Divider || item is Header
+ } else {
+ true
+ }
+ }
+ }
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
- detailModel.setGenreUid(args.genreUid)
- collectImmediately(detailModel.currentGenre, ::updateItem)
+ detailModel.setGenre(args.genreUid)
+ collectImmediately(detailModel.currentGenre, ::updatePlaylist)
collectImmediately(detailModel.genreList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@@ -106,7 +118,7 @@ class GenreDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
- binding.detailToolbar.setOnMenuItemClickListener(null)
+ binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
@@ -130,6 +142,10 @@ class GenreDetailFragment :
requireContext().showToast(R.string.lng_queue_added)
true
}
+ R.id.action_playlist_add -> {
+ musicModel.addToPlaylist(currentGenre)
+ true
+ }
else -> false
}
}
@@ -154,7 +170,7 @@ class GenreDetailFragment :
override fun onOpenMenu(item: Music, anchor: View) {
when (item) {
- is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
+ is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item)
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
else -> error("Unexpected datatype: ${item::class.simpleName}")
}
@@ -170,8 +186,10 @@ class GenreDetailFragment :
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_genre_sort) {
+ // Select the corresponding sort mode option
val sort = detailModel.genreSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
+ // Select the corresponding sort direction option
val directionItemId =
when (sort.direction) {
Sort.Direction.ASCENDING -> R.id.option_sort_asc
@@ -182,8 +200,10 @@ class GenreDetailFragment :
item.isChecked = !item.isChecked
detailModel.genreSongSort =
when (item.itemId) {
+ // Sort direction options
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
+ // Any other option is a sort mode
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
true
@@ -191,13 +211,13 @@ class GenreDetailFragment :
}
}
- private fun updateItem(genre: Genre?) {
+ private fun updatePlaylist(genre: Genre?) {
if (genre == null) {
// Genre we were showing no longer exists.
findNavController().navigateUp()
return
}
- requireBinding().detailToolbar.title = genre.resolveName(requireContext())
+ requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
genreHeaderAdapter.setParent(genre)
}
@@ -233,7 +253,7 @@ class GenreDetailFragment :
is Genre -> {
navModel.exploreNavigationItem.consume()
}
- null -> {}
+ else -> {}
}
}
@@ -243,6 +263,13 @@ class GenreDetailFragment :
private fun updateSelection(selected: List) {
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)
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt
new file mode 100644
index 000000000..c7cf92cde
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt
@@ -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 .
+ */
+
+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(),
+ 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
- ) {
+ playlistListAdapter.update(list, detailModel.playlistInstructions.consume())
+ }
+
+ private fun updateEditedPlaylist(editedPlaylist: List?) {
+ 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) {
+ 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)
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt
index 337759103..5ba78ea8f 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt
@@ -34,7 +34,8 @@ import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.metadata.AudioInfo
+import org.oxycblt.auxio.music.info.Name
+import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
@@ -66,11 +67,11 @@ class SongDetailDialog : ViewBindingDialogFragment() {
super.onBindingCreated(binding, savedInstanceState)
binding.detailProperties.adapter = detailAdapter
// DetailViewModel handles most initialization from the navigation argument.
- detailModel.setSongUid(args.itemUid)
- collectImmediately(detailModel.currentSong, detailModel.songAudioInfo, ::updateSong)
+ detailModel.setSong(args.songUid)
+ collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
}
- private fun updateSong(song: Song?, info: AudioInfo?) {
+ private fun updateSong(song: Song?, info: AudioProperties?) {
if (song == null) {
// Song we were showing no longer exists.
findNavController().navigateUp()
@@ -123,12 +124,14 @@ class SongDetailDialog : ViewBindingDialogFragment() {
}
}
- private fun T.zipName(context: Context) =
- if (rawSortName != null) {
- getString(R.string.fmt_zipped_names, resolveName(context), rawSortName)
+ private fun T.zipName(context: Context): String {
+ val name = name
+ return if (name is Name.Known && name.sort != null) {
+ getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
} else {
- resolveName(context)
+ name.resolve(context)
}
+ }
private fun List.zipNames(context: Context) =
concatLocalized(context) { it.zipName(context) }
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt
index 07a552c55..41c12d9d6 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt
@@ -33,6 +33,7 @@ import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Album] information.
*
+ * @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderAdapter(private val listener: Listener) :
@@ -76,7 +77,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
- binding.detailName.text = album.resolveName(binding.context)
+ binding.detailName.text = album.name.resolve(binding.context)
// Artist name maps to the subhead text
binding.detailSubhead.apply {
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt
index 1268f7caf..3b346975e 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt
@@ -33,6 +33,7 @@ import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Artist] information.
*
+ * @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderAdapter(private val listener: Listener) :
@@ -62,7 +63,18 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(artist)
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
- binding.detailName.text = artist.resolveName(binding.context)
+ binding.detailName.text = artist.name.resolve(binding.context)
+
+ // Song and album counts map to the info
+ binding.detailInfo.text =
+ binding.context.getString(
+ R.string.fmt_two,
+ binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
+ if (artist.songs.isNotEmpty()) {
+ binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
+ } else {
+ binding.context.getString(R.string.def_song_count)
+ })
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text
@@ -71,13 +83,6 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
text = artist.genres.resolveNames(context)
}
- // Song and album counts map to the info
- binding.detailInfo.text =
- binding.context.getString(
- R.string.fmt_two,
- binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
- binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size))
-
// In the case that this header used to he configured to have no songs,
// we want to reset the visibility of all information that was hidden.
binding.detailPlayButton.isVisible = true
@@ -87,10 +92,8 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
// ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those.
binding.detailSubhead.isVisible = false
- binding.detailInfo.text =
- binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
- binding.detailPlayButton.isVisible = false
- binding.detailShuffleButton.isVisible = false
+ binding.detailPlayButton.isEnabled = false
+ binding.detailShuffleButton.isEnabled = false
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt
index 541ed30d9..06317f5e2 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt
@@ -48,10 +48,17 @@ abstract class DetailHeaderAdapter.
+ */
+
+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() {
+ private var editedPlaylist: List? = 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?) {
+ 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?,
+ 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))
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt
index 84c8683ad..b7217a681 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt
@@ -33,7 +33,7 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.metadata.Disc
+import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
@@ -69,15 +69,6 @@ class AlbumDetailListAdapter(private val listener: Listener) :
}
}
- override fun isItemFullWidth(position: Int): Boolean {
- if (super.isItemFullWidth(position)) {
- return true
- }
- // The album and disc headers should be full-width in all configurations.
- val item = getItem(position)
- return item is Album || item is Disc
- }
-
private companion object {
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@@ -171,7 +162,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
}
}
- binding.songName.text = song.resolveName(binding.context)
+ binding.songName.text = song.name.resolve(binding.context)
// Use duration instead of album or artist for each song, as this text would
// be homogenous otherwise.
@@ -204,7 +195,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
val DIFF_CALLBACK =
object : SimpleDiffCallback() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
- oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
+ oldItem.name == newItem.name && oldItem.durationMs == newItem.durationMs
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt
index c23c7c20c..e281d9982 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt
@@ -65,14 +65,6 @@ class ArtistDetailListAdapter(private val listener: Listener) :
}
}
- override fun isItemFullWidth(position: Int): Boolean {
- if (super.isItemFullWidth(position)) {
- return true
- }
- // Artist headers should be full-width in all configurations.
- return getItem(position) is Artist
- }
-
private companion object {
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
@@ -106,7 +98,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
fun bind(album: Album, listener: SelectableListListener) {
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
- binding.parentName.text = album.resolveName(binding.context)
+ binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information
album.dates?.resolveDate(binding.context)
@@ -139,7 +131,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
val DIFF_CALLBACK =
object : SimpleDiffCallback() {
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) {
listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
- binding.songName.text = song.resolveName(binding.context)
- binding.songInfo.text = song.album.resolveName(binding.context)
+ binding.songName.text = song.name.resolve(binding.context)
+ binding.songInfo.text = song.album.name.resolve(binding.context)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@@ -191,8 +183,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
val DIFF_CALLBACK =
object : SimpleDiffCallback() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
- oldItem.rawName == newItem.rawName &&
- oldItem.album.rawName == newItem.album.rawName
+ oldItem.name == newItem.name && oldItem.album.name == newItem.album.name
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt
index 7959ec47d..9c43dc875 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt
@@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.list.BasicHeader
+import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
@@ -47,13 +48,12 @@ import org.oxycblt.auxio.util.inflater
abstract class DetailListAdapter(
private val listener: Listener<*>,
private val diffCallback: DiffUtil.ItemCallback
-
-) :
- SelectionIndicatorAdapter
- (diffCallback),
- AuxioRecyclerView.SpanSizeLookup {
+) : SelectionIndicatorAdapter
- (diffCallback) {
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Implement support for headers and sort headers
+ is Divider -> DividerViewHolder.VIEW_TYPE
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
@@ -61,6 +61,7 @@ abstract class DetailListAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
+ DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent)
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType")
@@ -73,12 +74,6 @@ abstract class DetailListAdapter(
}
}
- override fun isItemFullWidth(position: Int): Boolean {
- // Headers should be full-width in all configurations.
- val item = getItem(position)
- return item is BasicHeader || item is SortHeader
- }
-
/** An extended [SelectableListListener] for [DetailListAdapter] implementations. */
interface Listener : SelectableListListener {
/**
@@ -94,6 +89,8 @@ abstract class DetailListAdapter(
object : SimpleDiffCallback
- () {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
+ oldItem is Divider && newItem is Divider ->
+ DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is BasicHeader && newItem is BasicHeader ->
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader ->
@@ -114,8 +111,8 @@ abstract class DetailListAdapter(
data class SortHeader(@StringRes override val titleRes: Int) : Header
/**
- * A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [BasicHeader] that adds
- * a button opening a menu for sorting. Use [from] to create an instance.
+ * A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create
+ * an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@@ -129,7 +126,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
*/
fun bind(sortHeader: SortHeader, listener: DetailListAdapter.Listener<*>) {
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
- binding.headerButton.apply {
+ binding.headerSort.apply {
// Add a Tooltip based on the content description so that the purpose of this
// button can be clear.
TooltipCompat.setTooltipText(this, contentDescription)
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt
index 67ebe3781..5f2c704f2 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt
@@ -30,7 +30,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
/**
- * An [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
+ * A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
*
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
@@ -60,14 +60,6 @@ class GenreDetailListAdapter(private val listener: Listener) :
}
}
- override fun isItemFullWidth(position: Int): Boolean {
- if (super.isItemFullWidth(position)) {
- return true
- }
- // Genre headers should be full-width in all configurations
- return getItem(position) is Genre
- }
-
private companion object {
val DIFF_CALLBACK =
object : SimpleDiffCallback
- () {
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt
new file mode 100644
index 000000000..7b4147621
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt
@@ -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 .
+ */
+
+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
+ ) {
+ 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, 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
- () {
+ 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() {
+ 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
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDragCallback.kt
new file mode 100644
index 000000000..c93514e14
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDragCallback.kt
@@ -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 .
+ */
+
+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)
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt
new file mode 100644
index 000000000..2b0cd3d5e
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt
@@ -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 .
+ */
+
+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()
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
index 65b9e6f35..5f26f32d5 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
@@ -46,20 +46,15 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding
-import org.oxycblt.auxio.home.list.AlbumListFragment
-import org.oxycblt.auxio.home.list.ArtistListFragment
-import org.oxycblt.auxio.home.list.GenreListFragment
-import org.oxycblt.auxio.home.list.SongListFragment
+import org.oxycblt.auxio.home.list.*
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionFragment
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.music.model.Library
-import org.oxycblt.auxio.music.system.Indexer
+import org.oxycblt.auxio.navigation.MainNavigationAction
+import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
-import org.oxycblt.auxio.ui.MainNavigationAction
-import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
/**
@@ -73,8 +68,8 @@ class HomeFragment :
SelectionFragment(), AppBarLayout.OnOffsetChangedListener {
override val playbackModel: PlaybackViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
+ override val musicModel: MusicViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels()
- private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher? = null
@@ -107,7 +102,7 @@ class HomeFragment :
// --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this)
- binding.homeToolbar.apply {
+ binding.homeNormalToolbar.apply {
setOnMenuItemClickListener(this@HomeFragment)
MenuCompat.setGroupDividerEnabled(menu, true)
}
@@ -152,13 +147,11 @@ class HomeFragment :
// re-creating the ViewPager.
setupPager(binding)
- binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
-
// --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate)
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
- collectImmediately(musicModel.indexerState, ::updateIndexerState)
+ collectImmediately(musicModel.indexingState, ::updateIndexerState)
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
}
@@ -176,7 +169,7 @@ class HomeFragment :
super.onDestroyBinding(binding)
storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this)
- binding.homeToolbar.setOnMenuItemClickListener(null)
+ binding.homeNormalToolbar.setOnMenuItemClickListener(null)
}
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
@@ -185,8 +178,7 @@ class HomeFragment :
// Fade out the toolbar as the AppBarLayout collapses. To prevent status bar overlap,
// the alpha transition is shifted such that the Toolbar becomes fully transparent
// when the AppBarLayout is only at half-collapsed.
- binding.homeSelectionToolbar.alpha =
- 1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
+ binding.homeToolbar.alpha = 1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
binding.homeContent.updatePadding(
bottom = binding.homeAppbar.totalScrollRange + verticalOffset)
}
@@ -250,7 +242,7 @@ class HomeFragment :
binding.homePager.adapter =
HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner)
- val toolbarParams = binding.homeSelectionToolbar.layoutParams as AppBarLayout.LayoutParams
+ val toolbarParams = binding.homeToolbar.layoutParams as AppBarLayout.LayoutParams
if (homeModel.currentTabModes.size == 1) {
// A single tab makes the tab layout redundant, hide it and disable the collapsing
// behavior.
@@ -273,6 +265,7 @@ class HomeFragment :
}
private fun updateCurrentTab(tabMode: MusicMode) {
+ val binding = requireBinding()
// Update the sort options to align with those allowed by the tab
val isVisible: (Int) -> Boolean =
when (tabMode) {
@@ -280,16 +273,8 @@ class HomeFragment :
MusicMode.SONGS -> { id -> id != R.id.option_sort_count }
// Disallow sorting by album for albums
MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album }
- // Only allow sorting by name, count, and duration for artists
- MusicMode.ARTISTS -> { id ->
- id == R.id.option_sort_asc ||
- id == R.id.option_sort_dec ||
- id == R.id.option_sort_name ||
- id == R.id.option_sort_count ||
- id == R.id.option_sort_duration
- }
- // Only allow sorting by name, count, and duration for genres
- MusicMode.GENRES -> { id ->
+ // Only allow sorting by name, count, and duration for parents
+ else -> { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_dec ||
id == R.id.option_sort_name ||
@@ -299,8 +284,7 @@ class HomeFragment :
}
val sortMenu =
- unlikelyToBeNull(
- requireBinding().homeToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
+ unlikelyToBeNull(binding.homeNormalToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
val toHighlight = homeModel.getSortForTab(tabMode)
for (option in sortMenu) {
@@ -321,13 +305,24 @@ class HomeFragment :
// Update the scrolling view in AppBarLayout to align with the current tab's
// scrolling state. This prevents the lift state from being confused as one
// goes between different tabs.
- requireBinding().homeAppbar.liftOnScrollTargetViewId =
+ binding.homeAppbar.liftOnScrollTargetViewId =
when (tabMode) {
MusicMode.SONGS -> R.id.home_song_recycler
MusicMode.ALBUMS -> R.id.home_album_recycler
MusicMode.ARTISTS -> R.id.home_artist_recycler
MusicMode.GENRES -> R.id.home_genre_recycler
+ MusicMode.PLAYLISTS -> R.id.home_playlist_recycler
}
+
+ if (tabMode != MusicMode.PLAYLISTS) {
+ binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
+ playbackModel.shuffleAll()
+ }
+ } else {
+ binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
+ musicModel.createPlaylist()
+ }
+ }
}
private fun handleRecreate(recreate: Unit?) {
@@ -340,14 +335,14 @@ class HomeFragment :
homeModel.recreateTabs.consume()
}
- private fun updateIndexerState(state: Indexer.State?) {
+ private fun updateIndexerState(state: IndexingState?) {
// TODO: Make music loading experience a bit more pleasant
// 1. Loading placeholder for item lists
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
val binding = requireBinding()
when (state) {
- is Indexer.State.Complete -> setupCompleteState(binding, state.result)
- is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
+ is IndexingState.Completed -> setupCompleteState(binding, state.error)
+ is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
null -> {
logD("Indexer is in indeterminate state")
binding.homeIndexingContainer.visibility = View.INVISIBLE
@@ -355,77 +350,77 @@ class HomeFragment :
}
}
- private fun setupCompleteState(binding: FragmentHomeBinding, result: Result) {
- if (result.isSuccess) {
+ private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) {
+ if (error == null) {
logD("Received ok response")
binding.homeFab.show()
binding.homeIndexingContainer.visibility = View.INVISIBLE
- } else {
- logD("Received non-ok response")
- val context = requireContext()
- val throwable = unlikelyToBeNull(result.exceptionOrNull())
- binding.homeIndexingContainer.visibility = View.VISIBLE
- binding.homeIndexingProgress.visibility = View.INVISIBLE
- when (throwable) {
- is Indexer.NoPermissionException -> {
- logD("Updating UI to permission request state")
- binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
- // Configure the action to act as a permission launcher.
- binding.homeIndexingAction.apply {
- visibility = View.VISIBLE
- text = context.getString(R.string.lbl_grant)
- setOnClickListener {
- requireNotNull(storagePermissionLauncher) {
- "Permission launcher was not available"
- }
- .launch(Indexer.PERMISSION_READ_AUDIO)
- }
+ return
+ }
+
+ logD("Received non-ok response")
+ val context = requireContext()
+ binding.homeIndexingContainer.visibility = View.VISIBLE
+ binding.homeIndexingProgress.visibility = View.INVISIBLE
+ when (error) {
+ is NoAudioPermissionException -> {
+ logD("Updating UI to permission request state")
+ binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
+ // Configure the action to act as a permission launcher.
+ binding.homeIndexingAction.apply {
+ visibility = View.VISIBLE
+ text = context.getString(R.string.lbl_grant)
+ setOnClickListener {
+ requireNotNull(storagePermissionLauncher) {
+ "Permission launcher was not available"
+ }
+ .launch(PERMISSION_READ_AUDIO)
}
}
- is Indexer.NoMusicException -> {
- logD("Updating UI to no music state")
- binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
- // Configure the action to act as a reload trigger.
- binding.homeIndexingAction.apply {
- visibility = View.VISIBLE
- text = context.getString(R.string.lbl_retry)
- setOnClickListener { musicModel.refresh() }
- }
+ }
+ is NoMusicException -> {
+ logD("Updating UI to no music state")
+ binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
+ // Configure the action to act as a reload trigger.
+ binding.homeIndexingAction.apply {
+ visibility = View.VISIBLE
+ text = context.getString(R.string.lbl_retry)
+ setOnClickListener { musicModel.refresh() }
}
- else -> {
- logD("Updating UI to error state")
- binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
- // Configure the action to act as a reload trigger.
- binding.homeIndexingAction.apply {
- visibility = View.VISIBLE
- text = context.getString(R.string.lbl_retry)
- setOnClickListener { musicModel.rescan() }
- }
+ }
+ else -> {
+ logD("Updating UI to error state")
+ binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
+ // Configure the action to act as a reload trigger.
+ binding.homeIndexingAction.apply {
+ visibility = View.VISIBLE
+ text = context.getString(R.string.lbl_retry)
+ setOnClickListener { musicModel.rescan() }
}
}
}
}
- private fun setupIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) {
+ private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
// Remove all content except for the progress indicator.
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.VISIBLE
binding.homeIndexingAction.visibility = View.INVISIBLE
- when (indexing) {
- is Indexer.Indexing.Indeterminate -> {
+ when (progress) {
+ is IndexingProgress.Indeterminate -> {
// In a query/initialization state, show a generic loading status.
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
binding.homeIndexingProgress.isIndeterminate = true
}
- is Indexer.Indexing.Songs -> {
+ is IndexingProgress.Songs -> {
// Actively loading songs, show the current progress.
binding.homeIndexingStatus.text =
- getString(R.string.fmt_indexing, indexing.current, indexing.total)
+ getString(R.string.fmt_indexing, progress.current, progress.total)
binding.homeIndexingProgress.apply {
isIndeterminate = false
- max = indexing.total
- progress = indexing.current
+ max = progress.total
+ this.progress = progress.current
}
}
}
@@ -450,7 +445,8 @@ class HomeFragment :
is Album -> HomeFragmentDirections.actionShowAlbum(item.uid)
is Artist -> HomeFragmentDirections.actionShowArtist(item.uid)
is Genre -> HomeFragmentDirections.actionShowGenre(item.uid)
- else -> return
+ is Playlist -> HomeFragmentDirections.actionShowPlaylist(item.uid)
+ null -> return
}
setupAxisTransitions(MaterialSharedAxis.X)
@@ -459,11 +455,15 @@ class HomeFragment :
private fun updateSelection(selected: List) {
val binding = requireBinding()
- if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
- selected.isNotEmpty()) {
- // New selection started, show the AppBarLayout to indicate the new state.
- logD("Significant selection occurred, expanding AppBar")
- binding.homeAppbar.expandWithScrollingRecycler()
+ if (selected.isNotEmpty()) {
+ binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
+ if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
+ // New selection started, show the AppBarLayout to indicate the new state.
+ logD("Significant selection occurred, expanding AppBar")
+ binding.homeAppbar.expandWithScrollingRecycler()
+ }
+ } else {
+ binding.homeToolbar.setVisible(R.id.home_normal_toolbar)
}
}
@@ -499,6 +499,7 @@ class HomeFragment :
MusicMode.ALBUMS -> AlbumListFragment()
MusicMode.ARTISTS -> ArtistListFragment()
MusicMode.GENRES -> GenreListFragment()
+ MusicMode.PLAYLISTS -> PlaylistListFragment()
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
index 776b0b219..60d3144e7 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
@@ -24,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab
+import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.unlikelyToBeNull
@@ -64,10 +65,32 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
override val shouldHideCollaborators: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false)
+ override fun migrate() {
+ if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
+ val oldTabs =
+ Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
+ ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
+
+ // The playlist tab is now parsed, but it needs to be made visible.
+ val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS }
+ if (playlistIndex > -1) { // Sanity check
+ oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS)
+ }
+ sharedPreferences.edit {
+ putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
+ remove(OLD_KEY_LIB_TABS)
+ }
+ }
+ }
+
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
when (key) {
getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged()
}
}
+
+ companion object {
+ const val OLD_KEY_LIB_TABS = "auxio_lib_tabs"
+ }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
index adcb3473e..8b4e6d581 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
@@ -27,7 +27,6 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
@@ -46,7 +45,7 @@ constructor(
private val playbackSettings: PlaybackSettings,
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings
-) : ViewModel(), MusicRepository.Listener, HomeSettings.Listener {
+) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
private val _songsList = MutableStateFlow(listOf())
/** 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
get() = _genresInstructions
+ private val _playlistsList = MutableStateFlow(listOf())
+ /** A list of [Playlist]s, sorted by the preferred [Sort], to be shown in the home view. */
+ val playlistsList: StateFlow
>
+ get() = _playlistsList
+ private val _playlistsInstructions = MutableEvent()
+ /** Instructions for how to update [genresList] in the UI. */
+ val playlistsInstructions: Event
+ get() = _playlistsInstructions
+
/** The [MusicMode] to use when playing a [Song] from the UI. */
val playbackMode: MusicMode
get() = playbackSettings.inListPlaybackMode
@@ -117,37 +125,45 @@ constructor(
val isFastScrolling: StateFlow = _isFastScrolling
init {
- musicRepository.addListener(this)
+ musicRepository.addUpdateListener(this)
homeSettings.registerListener(this)
}
override fun onCleared() {
super.onCleared()
- musicRepository.removeListener(this)
+ musicRepository.removeUpdateListener(this)
homeSettings.unregisterListener(this)
}
- override fun onLibraryChanged(library: Library?) {
- if (library != null) {
- logD("Library changed, refreshing library")
- // FIXME: Sort name setting changes result in incorrect list updates
+ override fun onMusicChanges(changes: MusicRepository.Changes) {
+ val deviceLibrary = musicRepository.deviceLibrary
+ logD(changes.deviceLibrary)
+ if (changes.deviceLibrary && deviceLibrary != null) {
+ logD("Refreshing library")
// Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them.
_songsInstructions.put(UpdateInstructions.Diff)
- _songsList.value = musicSettings.songSort.songs(library.songs)
+ _songsList.value = musicSettings.songSort.songs(deviceLibrary.songs)
_albumsInstructions.put(UpdateInstructions.Diff)
- _albumsLists.value = musicSettings.albumSort.albums(library.albums)
+ _albumsLists.value = musicSettings.albumSort.albums(deviceLibrary.albums)
_artistsInstructions.put(UpdateInstructions.Diff)
_artistsList.value =
musicSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) {
// Hide Collaborators is enabled, filter out collaborators.
- library.artists.filter { !it.isCollaborator }
+ deviceLibrary.artists.filter { !it.isCollaborator }
} else {
- library.artists
+ deviceLibrary.artists
})
_genresInstructions.put(UpdateInstructions.Diff)
- _genresList.value = musicSettings.genreSort.genres(library.genres)
+ _genresList.value = musicSettings.genreSort.genres(deviceLibrary.genres)
+ }
+
+ val userLibrary = musicRepository.userLibrary
+ if (changes.userLibrary && userLibrary != null) {
+ logD("Refreshing playlists")
+ _playlistsInstructions.put(UpdateInstructions.Diff)
+ _playlistsList.value = musicSettings.playlistSort.playlists(userLibrary.playlists)
}
}
@@ -160,7 +176,7 @@ constructor(
override fun onHideCollaboratorsChanged() {
// Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update.
- onLibraryChanged(musicRepository.library)
+ onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
}
/**
@@ -175,6 +191,7 @@ constructor(
MusicMode.ALBUMS -> musicSettings.albumSort
MusicMode.ARTISTS -> musicSettings.artistSort
MusicMode.GENRES -> musicSettings.genreSort
+ MusicMode.PLAYLISTS -> musicSettings.playlistSort
}
/**
@@ -206,6 +223,11 @@ constructor(
_genresInstructions.put(UpdateInstructions.Replace(0))
_genresList.value = sort.genres(_genresList.value)
}
+ MusicMode.PLAYLISTS -> {
+ musicSettings.playlistSort = sort
+ _playlistsInstructions.put(UpdateInstructions.Replace(0))
+ _playlistsList.value = sort.playlists(_playlistsList.value)
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
index b5b9135dd..a17172d08 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
@@ -37,10 +37,10 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.*
+import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
-import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
/**
@@ -56,6 +56,7 @@ class AlbumListFragment :
private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
private val albumAdapter = AlbumAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text
@@ -94,10 +95,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
// By Name -> Use Name
- is Sort.Mode.ByName -> album.sortName?.thumbString
+ is Sort.Mode.ByName -> album.name.thumb
// By Artist -> Use name of first artist
- is Sort.Mode.ByArtist -> album.artists[0].sortName?.thumbString
+ is Sort.Mode.ByArtist -> album.artists[0].name.thumb
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
index c6a58f594..7eb5c88a0 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
@@ -38,9 +38,10 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
+import org.oxycblt.auxio.music.MusicViewModel
+import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
-import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.nonZeroOrNull
@@ -58,6 +59,7 @@ class ArtistListFragment :
private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
private val artistAdapter = ArtistAdapter(this)
@@ -93,7 +95,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
// By Name -> Use Name
- is Sort.Mode.ByName -> artist.sortName?.thumbString
+ is Sort.Mode.ByName -> artist.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
@@ -115,7 +117,7 @@ class ArtistListFragment :
}
override fun onOpenMenu(item: Artist, anchor: View) {
- openMusicMenu(anchor, R.menu.menu_artist_actions, item)
+ openMusicMenu(anchor, R.menu.menu_parent_actions, item)
}
private fun updateArtists(artists: List) {
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
index 3561abbb4..8b2cab6f3 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
@@ -38,9 +38,10 @@ import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
+import org.oxycblt.auxio.music.MusicViewModel
+import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
-import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
@@ -57,6 +58,7 @@ class GenreListFragment :
private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
private val genreAdapter = GenreAdapter(this)
@@ -92,7 +94,7 @@ class GenreListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
// By Name -> Use Name
- is Sort.Mode.ByName -> genre.sortName?.thumbString
+ is Sort.Mode.ByName -> genre.name.thumb
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@@ -114,7 +116,7 @@ class GenreListFragment :
}
override fun onOpenMenu(item: Genre, anchor: View) {
- openMusicMenu(anchor, R.menu.menu_artist_actions, item)
+ openMusicMenu(anchor, R.menu.menu_parent_actions, item)
}
private fun updateGenres(genres: List) {
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt
new file mode 100644
index 000000000..4c5d8d19a
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt
@@ -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 .
+ */
+
+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(),
+ 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) {
+ playlistAdapter.update(
+ playlists, homeModel.playlistsInstructions.consume().also { logD(it) })
+ }
+
+ private fun updateSelection(selection: List) {
+ 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) :
+ SelectionIndicatorAdapter(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)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
index 9dc512b99..a21a470df 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
@@ -39,11 +39,12 @@ import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
+import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
-import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
/**
@@ -59,6 +60,7 @@ class SongListFragment :
private val homeModel: HomeViewModel by activityViewModels()
override val navModel: NavigationViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val musicModel: MusicViewModel by activityViewModels()
override val selectionModel: SelectionViewModel by activityViewModels()
private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text
@@ -100,13 +102,13 @@ class SongListFragment :
// based off the names of the parent objects and not the child objects.
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
// Name -> Use name
- is Sort.Mode.ByName -> song.sortName?.thumbString
+ is Sort.Mode.ByName -> song.name.thumb
// Artist -> Use name of first artist
- is Sort.Mode.ByArtist -> song.album.artists[0].sortName?.thumbString
+ is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb
// Album -> Use Album Name
- is Sort.Mode.ByAlbum -> song.album.sortName?.thumbString
+ is Sort.Mode.ByAlbum -> song.album.name.thumb
// Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt
index e39c4a90f..718c99855 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt
@@ -58,6 +58,10 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) :
icon = R.drawable.ic_genre_24
string = R.string.lbl_genres
}
+ MusicMode.PLAYLISTS -> {
+ icon = R.drawable.ic_playlist_24
+ string = R.string.lbl_playlists
+ }
}
// Use expected sw* size thresholds when choosing a configuration.
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt
index e4aeb5d57..30425e6d8 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt
@@ -49,7 +49,7 @@ sealed class Tab(open val mode: MusicMode) {
//
// 0bTAB1_TAB2_TAB3_TAB4_TAB5
//
- // Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists.
+ // Where TABN is a chunk representing a tab at position N.
// Each chunk in a sequence is represented as:
//
// VTTT
@@ -57,18 +57,23 @@ sealed class Tab(open val mode: MusicMode) {
// Where V is a bit representing the visibility and T is a 3-bit integer representing the
// MusicMode for this tab.
- /** The length a well-formed tab sequence should be. */
- private const val SEQUENCE_LEN = 4
+ /** The maximum index that a well-formed tab sequence should be. */
+ private const val MAX_SEQUENCE_IDX = 4
/**
* The default tab sequence, in integer form. This represents a set of four visible tabs
- * ordered as "Song", "Album", "Artist", and "Genre".
+ * ordered as "Song", "Album", "Artist", "Genre", and "Playlists
*/
- const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100
+ const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_1100
/** Maps between the integer code in the tab sequence and it's [MusicMode]. */
private val MODE_TABLE =
- arrayOf(MusicMode.SONGS, MusicMode.ALBUMS, MusicMode.ARTISTS, MusicMode.GENRES)
+ arrayOf(
+ MusicMode.SONGS,
+ MusicMode.ALBUMS,
+ MusicMode.ARTISTS,
+ MusicMode.GENRES,
+ MusicMode.PLAYLISTS)
/**
* Convert an array of [Tab]s into it's integer representation.
@@ -80,8 +85,8 @@ sealed class Tab(open val mode: MusicMode) {
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
val distinct = tabs.distinctBy { it.mode }
- var sequence = 0b0100
- var shift = SEQUENCE_LEN * 4
+ var sequence = 0
+ var shift = MAX_SEQUENCE_IDX * 4
for (tab in distinct) {
val bin =
when (tab) {
@@ -107,9 +112,8 @@ sealed class Tab(open val mode: MusicMode) {
// Try to parse a mode for each chunk in the sequence.
// If we can't parse one, just skip it.
- for (shift in (0..4 * SEQUENCE_LEN).reversed() step 4) {
+ for (shift in (0..MAX_SEQUENCE_IDX * 4).reversed() step 4) {
val chunk = intCode.shr(shift) and 0b1111
-
val mode = MODE_TABLE.getOrNull(chunk and 7) ?: continue
// Figure out the visibility
@@ -125,7 +129,7 @@ sealed class Tab(open val mode: MusicMode) {
val distinct = tabs.distinctBy { it.mode }
// For safety, return null if we have an empty or larger-than-expected tab array.
- if (distinct.isEmpty() || distinct.size < SEQUENCE_LEN) {
+ if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
logE("Sequence size was ${distinct.size}, which is invalid")
return null
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt
index de754bba9..9e778cca1 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt
@@ -24,7 +24,7 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemTabBinding
-import org.oxycblt.auxio.list.EditableListListener
+import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.inflater
@@ -32,9 +32,9 @@ import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
*
- * @param listener A [EditableListListener] for tab interactions.
+ * @param listener A [EditClickListListener] for tab interactions.
*/
-class TabAdapter(private val listener: EditableListListener) :
+class TabAdapter(private val listener: EditClickListListener) :
RecyclerView.Adapter() {
/** The current array of [Tab]s. */
var tabs = arrayOf()
@@ -97,10 +97,10 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
* Bind new data to this instance.
*
* @param tab The new [Tab] to bind.
- * @param listener A [EditableListListener] to bind interactions to.
+ * @param listener A [EditClickListListener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
- fun bind(tab: Tab, listener: EditableListListener) {
+ fun bind(tab: Tab, listener: EditClickListListener) {
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
binding.tabCheckBox.apply {
// Update the CheckBox name to align with the mode
@@ -110,6 +110,7 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
MusicMode.ALBUMS -> R.string.lbl_albums
MusicMode.ARTISTS -> R.string.lbl_artists
MusicMode.GENRES -> R.string.lbl_genres
+ MusicMode.PLAYLISTS -> R.string.lbl_playlists
})
// Unlike in other adapters, we update the checked state alongside
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
index 536a205bb..dae73e93e 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
@@ -29,7 +29,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.home.HomeSettings
-import org.oxycblt.auxio.list.EditableListListener
+import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.logD
@@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.logD
*/
@AndroidEntryPoint
class TabCustomizeDialog :
- ViewBindingDialogFragment(), EditableListListener {
+ ViewBindingDialogFragment(), EditClickListListener {
private val tabAdapter = TabAdapter(this)
private var touchHelper: ItemTouchHelper? = null
@Inject lateinit var homeSettings: HomeSettings
diff --git a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt
index 32bc3cd14..bd19c3a87 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt
@@ -95,7 +95,7 @@ constructor(
target
.onConfigRequest(
ImageRequest.Builder(context)
- .data(song)
+ .data(listOf(song))
// Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL)
.transformations(SquareFrameTransform.INSTANCE))
diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt
index 550f805e3..449f489fc 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt
@@ -30,10 +30,7 @@ import androidx.annotation.AttrRes
import androidx.core.view.updateMarginsRelative
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
-import org.oxycblt.auxio.music.Album
-import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.Genre
-import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimenPixels
@@ -52,6 +49,9 @@ import org.oxycblt.auxio.util.getInteger
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Rework content descriptions here
+ * TODO: Attempt unification with StyledImageView with some kind of dynamic configuration to avoid
+ * superfluous elements
+ * TODO: Handle non-square covers by gracefully placing them in the layout
*/
class ImageGroup
@JvmOverloads
@@ -177,6 +177,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(genre: Genre) = innerImageView.bind(genre)
+ /**
+ * Bind a [Playlist]'s image to the internal [StyledImageView].
+ *
+ * @param playlist the [Playlist] to bind.
+ * @see StyledImageView.bind
+ */
+ fun bind(playlist: Playlist) = innerImageView.bind(playlist)
+
/**
* Whether this view should be indicated to have ongoing playback or not. See
* PlaybackIndicatorView for more information on what occurs here. Note: It's expected for this
diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt
index ac9dd75c9..b3bb1cf2f 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt
@@ -18,16 +18,10 @@
package org.oxycblt.auxio.image
-import android.content.Context
-import coil.ImageLoader
-import coil.request.CachePolicy
import dagger.Binds
import dagger.Module
-import dagger.Provides
import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
-import javax.inject.Singleton
import org.oxycblt.auxio.image.extractor.*
@Module
@@ -35,31 +29,3 @@ import org.oxycblt.auxio.image.extractor.*
interface ImageModule {
@Binds fun settings(imageSettings: ImageSettingsImpl): ImageSettings
}
-
-@Module
-@InstallIn(SingletonComponent::class)
-class CoilModule {
- @Singleton
- @Provides
- fun imageLoader(
- @ApplicationContext context: Context,
- songFactory: AlbumCoverFetcher.SongFactory,
- albumFactory: AlbumCoverFetcher.AlbumFactory,
- artistFactory: ArtistImageFetcher.Factory,
- genreFactory: GenreImageFetcher.Factory
- ) =
- ImageLoader.Builder(context)
- .components {
- // Add fetchers for Music components to make them usable with ImageRequest
- add(MusicKeyer())
- add(songFactory)
- add(albumFactory)
- add(artistFactory)
- add(genreFactory)
- }
- // Use our own crossfade with error drawable support
- .transitionFactory(ErrorCrossfadeTransitionFactory())
- // Not downloading anything, so no disk-caching
- .diskCachePolicy(CachePolicy.DISABLED)
- .build()
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt
index 85ea9c730..2e04617e5 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt
@@ -38,11 +38,7 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
-import org.oxycblt.auxio.music.Album
-import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.Genre
-import org.oxycblt.auxio.music.Music
-import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
@@ -100,41 +96,54 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*
* @param song The [Song] to bind.
*/
- fun bind(song: Song) = bindImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover)
+ fun bind(song: Song) = bind(song.album)
/**
* Bind an [Album]'s cover to this view, also updating the content description.
*
* @param album the [Album] to bind.
*/
- fun bind(album: Album) = bindImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover)
+ fun bind(album: Album) = bind(album, R.drawable.ic_album_24, R.string.desc_album_cover)
/**
* Bind an [Artist]'s image to this view, also updating the content description.
*
* @param artist the [Artist] to bind.
*/
- fun bind(artist: Artist) = bindImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
+ fun bind(artist: Artist) = bind(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
/**
* Bind an [Genre]'s image to this view, also updating the content description.
*
* @param genre the [Genre] to bind.
*/
- fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
+ fun bind(genre: Genre) = bind(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
/**
- * Internally bind a [Music]'s image to this view.
+ * Bind a [Playlist]'s image to this view, also updating the content description.
*
- * @param music The music to find.
- * @param errorRes The error drawable resource to use if the music cannot be loaded.
- * @param descRes The content description string resource to use. The resource must have one
- * field for the name of the [Music].
+ * @param playlist The [Playlist] to bind.
+ * @param songs [Song]s that can override the playlist image if it needs to differ for any
+ * reason.
*/
- private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
+ fun bind(playlist: Playlist, songs: List? = 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, desc: String, @DrawableRes errorRes: Int) {
val request =
ImageRequest.Builder(context)
- .data(music)
+ .data(songs)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
.transformations(SquareFrameTransform.INSTANCE)
.target(this)
@@ -142,8 +151,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Dispose of any previous image request and load a new image.
CoilUtils.dispose(this)
imageLoader.enqueue(request)
- // Update the content description to the specified resource.
- contentDescription = context.getString(descRes, music.resolveName(context))
+ contentDescription = desc
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
index 8c9ff2e56..4e8e6d6d6 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
@@ -18,153 +18,31 @@
package org.oxycblt.auxio.image.extractor
-import android.content.Context
import coil.ImageLoader
-import coil.decode.DataSource
-import coil.decode.ImageSource
-import coil.fetch.FetchResult
import coil.fetch.Fetcher
-import coil.fetch.SourceResult
import coil.key.Keyer
import coil.request.Options
import coil.size.Size
import javax.inject.Inject
-import kotlin.math.min
-import okio.buffer
-import okio.source
-import org.oxycblt.auxio.list.Sort
-import org.oxycblt.auxio.music.Album
-import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.Genre
-import org.oxycblt.auxio.music.Music
-import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.*
-/**
- * A [Keyer] implementation for [Music] data.
- *
- * @author Alexander Capehart (OxygenCobalt)
- */
-class MusicKeyer : Keyer {
- override fun key(data: Music, options: Options) =
- if (data is Song) {
- // Group up song covers with album covers for better caching
- data.album.uid.toString()
- } else {
- data.uid.toString()
- }
+class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
+ Keyer> {
+ override fun key(data: List, options: Options) =
+ "${coverExtractor.computeAlbumOrdering(data).hashCode()}"
}
-/**
- * Generic [Fetcher] for [Album] covers. Works with both [Album] and [Song]. Use [SongFactory] or
- * [AlbumFactory] for instantiation.
- *
- * @author Alexander Capehart (OxygenCobalt)
- */
-class AlbumCoverFetcher
+class SongCoverFetcher
private constructor(
- private val context: Context,
- private val extractor: CoverExtractor,
- private val album: Album
-) : Fetcher {
- override suspend fun fetch(): FetchResult? =
- extractor.extract(album)?.run {
- SourceResult(
- source = ImageSource(source().buffer(), context),
- mimeType = null,
- dataSource = DataSource.DISK)
- }
-
- /** A [Fetcher.Factory] implementation that works with [Song]s. */
- class SongFactory @Inject constructor(private val coverExtractor: CoverExtractor) :
- Fetcher.Factory {
- 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 {
- override fun create(data: Album, options: Options, imageLoader: ImageLoader) =
- AlbumCoverFetcher(options.context, coverExtractor, data)
- }
-}
-
-/**
- * [Fetcher] for [Artist] images. Use [Factory] for instantiation.
- *
- * @author Alexander Capehart (OxygenCobalt)
- */
-class ArtistImageFetcher
-private constructor(
- private val context: Context,
- private val extractor: CoverExtractor,
+ private val songs: List,
private val size: Size,
- private val artist: Artist
+ private val coverExtractor: CoverExtractor,
) : Fetcher {
- override suspend fun fetch(): FetchResult? {
- // Pick the "most prominent" albums (i.e albums with the most songs) to show in the image.
- val albums = Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(artist.albums)
- val results = albums.mapAtMostNotNull(4) { album -> extractor.extract(album) }
- return Images.createMosaic(context, results, size)
- }
+ override suspend fun fetch() = coverExtractor.extract(songs, size)
- /** [Fetcher.Factory] implementation. */
- class Factory @Inject constructor(private val extractor: CoverExtractor) :
- Fetcher.Factory {
- override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
- ArtistImageFetcher(options.context, extractor, options.size, data)
+ class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
+ Fetcher.Factory> {
+ override fun create(data: List, options: Options, imageLoader: ImageLoader) =
+ SongCoverFetcher(data, options.size, coverExtractor)
}
}
-
-/**
- * [Fetcher] for [Genre] images. Use [Factory] for instantiation.
- *
- * @author Alexander Capehart (OxygenCobalt)
- */
-class GenreImageFetcher
-private constructor(
- private val context: Context,
- private val extractor: CoverExtractor,
- private val size: Size,
- private val genre: Genre
-) : Fetcher {
- override suspend fun fetch(): FetchResult? {
- val results = genre.albums.mapAtMostNotNull(4) { album -> extractor.extract(album) }
- return Images.createMosaic(context, results, size)
- }
-
- /** [Fetcher.Factory] implementation. */
- class Factory @Inject constructor(private val extractor: CoverExtractor) :
- Fetcher.Factory {
- 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 Collection.mapAtMostNotNull(
- n: Int,
- transform: (T) -> R?
-): List {
- val until = min(size, n)
- val out = mutableListOf()
-
- for (item in this) {
- if (out.size >= until) {
- break
- }
-
- // Still have more data we can transform.
- transform(item)?.let(out::add)
- }
-
- return out
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt
index 1c2bb113d..395429104 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt
@@ -19,13 +19,26 @@
package org.oxycblt.auxio.image.extractor
import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
import android.media.MediaMetadataRetriever
-import com.google.android.exoplayer2.MediaItem
-import com.google.android.exoplayer2.MediaMetadata
-import com.google.android.exoplayer2.MetadataRetriever
-import com.google.android.exoplayer2.metadata.flac.PictureFrame
-import com.google.android.exoplayer2.metadata.id3.ApicFrame
-import com.google.android.exoplayer2.source.MediaSource
+import android.util.Size as AndroidSize
+import androidx.core.graphics.drawable.toDrawable
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.exoplayer.MetadataRetriever
+import androidx.media3.exoplayer.source.MediaSource
+import androidx.media3.extractor.metadata.flac.PictureFrame
+import androidx.media3.extractor.metadata.id3.ApicFrame
+import coil.decode.DataSource
+import coil.decode.ImageSource
+import coil.fetch.DrawableResult
+import coil.fetch.FetchResult
+import coil.fetch.SourceResult
+import coil.size.Dimension
+import coil.size.Size
+import coil.size.pxOrElse
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream
import java.io.InputStream
@@ -33,9 +46,12 @@ import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.withContext
+import okio.buffer
+import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Album
+import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@@ -46,8 +62,28 @@ constructor(
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
+ suspend fun extract(songs: List, size: Size): FetchResult? {
+ val albums = computeAlbumOrdering(songs)
+ val streams = mutableListOf()
+ 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) =
+ songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key }
+
+ private suspend fun openInputStream(album: Album): InputStream? =
try {
when (imageSettings.coverMode) {
CoverMode.OFF -> null
@@ -123,8 +159,61 @@ constructor(
return stream
}
- @Suppress("BlockingMethodInNonBlockingContext")
private suspend fun extractMediaStoreCover(album: Album) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
+
+ /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
+ private suspend fun createMosaic(streams: List, 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
+ }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt
new file mode 100644
index 000000000..5f4145479
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt
@@ -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 .
+ */
+
+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()
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt
deleted file mode 100644
index 9be96132b..000000000
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt
+++ /dev/null
@@ -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 .
- */
-
-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,
- 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
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/Data.kt b/app/src/main/java/org/oxycblt/auxio/list/Data.kt
index 5fed1627d..e41dd4149 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/Data.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/Data.kt
@@ -40,3 +40,11 @@ interface Header : Item {
* @author Alexander Capehart (OxygenCobalt)
*/
data class BasicHeader(@StringRes override val titleRes: Int) : Header
+
+/**
+ * A divider decoration used to delimit groups of data.
+ *
+ * @param anchor The [Header] this divider should be next to in a list. Used as a way to preserve
+ * divider continuity during list updates.
+ */
+data class Divider(val anchor: Header?) : Item
diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
index dc2393772..49655d01b 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
@@ -22,7 +22,6 @@ import android.view.MenuItem
import android.view.View
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
-import androidx.core.internal.view.SupportMenu
import androidx.core.view.MenuCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
@@ -30,8 +29,8 @@ import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.selection.SelectionFragment
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.ui.MainNavigationAction
-import org.oxycblt.auxio.ui.NavigationViewModel
+import org.oxycblt.auxio.navigation.MainNavigationAction
+import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
@@ -59,7 +58,7 @@ abstract class ListFragment :
*/
abstract fun onRealClick(item: T)
- override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) {
+ final override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) {
if (selectionModel.selected.value.isNotEmpty()) {
// Map clicking an item to selecting an item when items are already selected.
selectionModel.select(item)
@@ -69,7 +68,7 @@ abstract class ListFragment :
}
}
- override fun onSelect(item: T) {
+ final override fun onSelect(item: T) {
selectionModel.select(item)
}
@@ -82,7 +81,7 @@ abstract class ListFragment :
* @param song The [Song] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) {
- logD("Launching new song menu: ${song.rawName}")
+ logD("Launching new song menu: ${song.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@@ -100,6 +99,9 @@ abstract class ListFragment :
R.id.action_go_album -> {
navModel.exploreNavigateTo(song.album)
}
+ R.id.action_playlist_add -> {
+ musicModel.addToPlaylist(song)
+ }
R.id.action_song_detail -> {
navModel.mainNavigateTo(
MainNavigationAction.Directions(
@@ -121,7 +123,7 @@ abstract class ListFragment :
* @param album The [Album] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) {
- logD("Launching new album menu: ${album.rawName}")
+ logD("Launching new album menu: ${album.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@@ -142,6 +144,9 @@ abstract class ListFragment :
R.id.action_go_artist -> {
navModel.exploreNavigateToParentArtist(album)
}
+ R.id.action_playlist_add -> {
+ musicModel.addToPlaylist(album)
+ }
else -> {
error("Unexpected menu item selected")
}
@@ -158,7 +163,7 @@ abstract class ListFragment :
* @param artist The [Artist] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) {
- logD("Launching new artist menu: ${artist.rawName}")
+ logD("Launching new artist menu: ${artist.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@@ -176,6 +181,9 @@ abstract class ListFragment :
playbackModel.addToQueue(artist)
requireContext().showToast(R.string.lng_queue_added)
}
+ R.id.action_playlist_add -> {
+ musicModel.addToPlaylist(artist)
+ }
else -> {
error("Unexpected menu item selected")
}
@@ -192,7 +200,7 @@ abstract class ListFragment :
* @param genre The [Genre] to create the menu for.
*/
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) {
- logD("Launching new genre menu: ${genre.rawName}")
+ logD("Launching new genre menu: ${genre.name}")
openMusicMenuImpl(anchor, menuRes) {
when (it.itemId) {
@@ -210,6 +218,49 @@ abstract class ListFragment :
playbackModel.addToQueue(genre)
requireContext().showToast(R.string.lng_queue_added)
}
+ R.id.action_playlist_add -> {
+ musicModel.addToPlaylist(genre)
+ }
+ else -> {
+ error("Unexpected menu item selected")
+ }
+ }
+ }
+ }
+
+ /**
+ * Opens a menu in the context of a [Playlist]. This menu will be managed by the Fragment and
+ * closed when the view is destroyed. If a menu is already opened, this call is ignored.
+ *
+ * @param anchor The [View] to anchor the menu to.
+ * @param menuRes The resource of the menu to load.
+ * @param playlist The [Playlist] to create the menu for.
+ */
+ protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) {
+ logD("Launching new playlist menu: ${playlist.name}")
+
+ openMusicMenuImpl(anchor, menuRes) {
+ when (it.itemId) {
+ R.id.action_play -> {
+ playbackModel.play(playlist)
+ }
+ R.id.action_shuffle -> {
+ playbackModel.shuffle(playlist)
+ }
+ R.id.action_play_next -> {
+ playbackModel.playNext(playlist)
+ requireContext().showToast(R.string.lng_queue_added)
+ }
+ R.id.action_queue_add -> {
+ playbackModel.addToQueue(playlist)
+ requireContext().showToast(R.string.lng_queue_added)
+ }
+ R.id.action_rename -> {
+ musicModel.renamePlaylist(playlist)
+ }
+ R.id.action_delete -> {
+ musicModel.deletePlaylist(playlist)
+ }
else -> {
error("Unexpected menu item selected")
}
@@ -247,7 +298,6 @@ abstract class ListFragment :
currentMenu =
PopupMenu(requireContext(), anchor).apply {
inflate(menuRes)
- logD(menu is SupportMenu)
MenuCompat.setGroupDividerEnabled(menu, true)
block()
setOnDismissListener { currentMenu = null }
diff --git a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt
index c102fcfef..d728a6142 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt
@@ -50,11 +50,11 @@ interface ClickableListListener {
}
/**
- * An extension of [ClickableListListener] that enables list editing functionality.
+ * A listener for lists that can be edited.
*
* @author Alexander Capehart (OxygenCobalt)
*/
-interface EditableListListener : ClickableListListener {
+interface EditableListListener {
/**
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
*
@@ -62,6 +62,29 @@ interface EditableListListener : ClickableListListener {
*/
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 : ClickableListListener, EditableListListener {
/**
* Binds this instance to a list item.
*
@@ -78,13 +101,7 @@ interface EditableListListener : ClickableListListener {
dragHandle: View
) {
bind(item, viewHolder, bodyView)
- dragHandle.setOnTouchListener { _, motionEvent ->
- dragHandle.performClick()
- if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
- onPickUp(viewHolder)
- true
- } else false
- }
+ bind(viewHolder, dragHandle)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt
index ec64cdb3c..5002e60cf 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt
@@ -24,8 +24,8 @@ import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort.Mode
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.music.metadata.Date
-import org.oxycblt.auxio.music.metadata.Disc
+import org.oxycblt.auxio.music.info.Date
+import org.oxycblt.auxio.music.info.Disc
/**
* A sorting method.
@@ -102,39 +102,40 @@ data class Sort(val mode: Mode, val direction: Direction) {
}
/**
- * Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
+ * Sort a list of [Playlist]s.
*
- * @param songs The [Song]s to sort.
+ * @param playlists The list of [Playlist]s.
+ * @return A new list of [Playlist]s sorted by this [Sort]'s configuration
*/
+ fun playlists(playlists: Collection): List {
+ val mutable = playlists.toMutableList()
+ playlistsInPlace(mutable)
+ return mutable
+ }
+
private fun songsInPlace(songs: MutableList) {
- 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) {
- 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) {
- 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) {
- genres.sortWith(mode.getGenreComparator(direction))
+ val comparator = mode.getGenreComparator(direction) ?: return
+ genres.sortWith(comparator)
+ }
+
+ private fun playlistsInPlace(playlists: MutableList) {
+ val comparator = mode.getPlaylistComparator(direction) ?: return
+ playlists.sortWith(comparator)
}
/**
@@ -154,58 +155,63 @@ data class Sort(val mode: Mode, val direction: Direction) {
}
/** Describes the type of data to sort with. */
- sealed class Mode {
+ sealed interface Mode {
/** The integer representation of this sort mode. */
- abstract val intCode: Int
+ val intCode: Int
/** The item ID of this sort mode in menu resources. */
- abstract val itemId: Int
+ val itemId: Int
/**
* Get a [Comparator] that sorts [Song]s according to this [Mode].
*
* @param direction The direction to sort in.
- * @return A [Comparator] that can be used to sort a [Song] list according to this [Mode].
+ * @return A [Comparator] that can be used to sort a [Song] list according to this [Mode],
+ * or null to not sort at all.
*/
- open fun getSongComparator(direction: Direction): Comparator {
- throw UnsupportedOperationException()
- }
+ fun getSongComparator(direction: Direction): Comparator? = null
/**
* Get a [Comparator] that sorts [Album]s according to this [Mode].
*
* @param direction The direction to sort in.
- * @return A [Comparator] that can be used to sort a [Album] list according to this [Mode].
+ * @return A [Comparator] that can be used to sort a [Album] list according to this [Mode],
+ * or null to not sort at all.
*/
- open fun getAlbumComparator(direction: Direction): Comparator {
- throw UnsupportedOperationException()
- }
+ fun getAlbumComparator(direction: Direction): Comparator? = null
/**
* Return a [Comparator] that sorts [Artist]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Artist] list according to this [Mode].
+ * or null to not sort at all.
*/
- open fun getArtistComparator(direction: Direction): Comparator {
- throw UnsupportedOperationException()
- }
+ fun getArtistComparator(direction: Direction): Comparator? = null
/**
* Return a [Comparator] that sorts [Genre]s according to this [Mode].
*
* @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
+ * or null to not sort at all.
*/
- open fun getGenreComparator(direction: Direction): Comparator {
- throw UnsupportedOperationException()
- }
+ fun getGenreComparator(direction: Direction): Comparator? = null
+
+ /**
+ * Return a [Comparator] that sorts [Playlist]s according to this [Mode].
+ *
+ * @param direction The direction to sort in.
+ * @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
+ * or null to not sort at all.
+ */
+ fun getPlaylistComparator(direction: Direction): Comparator? = null
/**
* Sort by the item's name.
*
- * @see Music.sortName
+ * @see Music.name
*/
- object ByName : Mode() {
+ object ByName : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_NAME
@@ -223,14 +229,17 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun getGenreComparator(direction: Direction) =
compareByDynamic(direction, BasicComparator.GENRE)
+
+ override fun getPlaylistComparator(direction: Direction) =
+ compareByDynamic(direction, BasicComparator.PLAYLIST)
}
/**
* Sort by the [Album] of an item. Only available for [Song]s.
*
- * @see Album.collationKey
+ * @see Album.name
*/
- object ByAlbum : Mode() {
+ object ByAlbum : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_ALBUM
@@ -248,9 +257,9 @@ data class Sort(val mode: Mode, val direction: Direction) {
/**
* Sort by the [Artist] name of an item. Only available for [Song] and [Album].
*
- * @see Artist.sortName
+ * @see Artist.name
*/
- object ByArtist : Mode() {
+ object ByArtist : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_ARTIST
@@ -279,7 +288,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @see Song.date
* @see Album.dates
*/
- object ByDate : Mode() {
+ object ByDate : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_YEAR
@@ -301,7 +310,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
}
/** Sort by the duration of an item. */
- object ByDuration : Mode() {
+ object ByDuration : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_DURATION
@@ -324,6 +333,11 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun getGenreComparator(direction: Direction): Comparator =
MultiComparator(
compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.GENRE))
+
+ override fun getPlaylistComparator(direction: Direction): Comparator =
+ MultiComparator(
+ compareByDynamic(direction) { it.durationMs },
+ compareBy(BasicComparator.PLAYLIST))
}
/**
@@ -331,7 +345,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
*
* @see MusicParent.songs
*/
- object ByCount : Mode() {
+ object ByCount : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_COUNT
@@ -350,6 +364,11 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun getGenreComparator(direction: Direction): Comparator =
MultiComparator(
compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.GENRE))
+
+ override fun getPlaylistComparator(direction: Direction): Comparator =
+ MultiComparator(
+ compareByDynamic(direction) { it.songs.size },
+ compareBy(BasicComparator.PLAYLIST))
}
/**
@@ -357,7 +376,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
*
* @see Song.disc
*/
- object ByDisc : Mode() {
+ object ByDisc : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_DISC
@@ -376,7 +395,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
*
* @see Song.track
*/
- object ByTrack : Mode() {
+ object ByTrack : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_TRACK
@@ -396,7 +415,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
* @see Song.dateAdded
* @see Album.dates
*/
- object ByDateAdded : Mode() {
+ object ByDateAdded : Mode {
override val intCode: Int
get() = IntegerTable.SORT_BY_DATE_ADDED
@@ -413,176 +432,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
compareBy(BasicComparator.ALBUM))
}
- /**
- * Utility function to create a [Comparator] in a dynamic way determined by [direction].
- *
- * @param direction The [Direction] to sort in.
- * @see compareBy
- * @see compareByDescending
- */
- protected inline fun > 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 compareByDynamic(
- direction: Direction,
- comparator: Comparator
- ): Comparator = 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 compareByDynamic(
- direction: Direction,
- comparator: Comparator,
- 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 compareBy(comparator: Comparator): Comparator =
- 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(vararg comparators: Comparator) : Comparator {
- 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(private val inner: Comparator) : Comparator> {
- override fun compare(a: List, b: List): 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> = 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 private constructor() : Comparator {
- 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 = BasicComparator()
- /** A re-usable instance configured for [Album]s. */
- val ALBUM: Comparator = BasicComparator()
- /** A re-usable instance configured for [Artist]s. */
- val ARTIST: Comparator = BasicComparator()
- /** A re-usable instance configured for [Genre]s. */
- val GENRE: Comparator = 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> private constructor() : Comparator {
- 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()
- /** A re-usable instance configured for [Long]s. */
- val LONG = NullableComparator()
- /** A re-usable instance configured for [Disc]s */
- val DISC = NullableComparator()
- /** A re-usable instance configured for [Date.Range]s. */
- val DATE_RANGE = NullableComparator()
- }
- }
-
companion object {
/**
* Convert a [Mode] integer representation into an instance.
@@ -652,3 +501,166 @@ data class Sort(val mode: Mode, val direction: Direction) {
}
}
}
+
+/**
+ * Utility function to create a [Comparator] in a dynamic way determined by [direction].
+ *
+ * @param direction The [Sort.Direction] to sort in.
+ * @see compareBy
+ * @see compareByDescending
+ */
+private inline fun > 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 compareByDynamic(
+ direction: Sort.Direction,
+ comparator: Comparator
+): Comparator = 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 compareByDynamic(
+ direction: Sort.Direction,
+ comparator: Comparator,
+ 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 compareBy(comparator: Comparator): Comparator =
+ 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(vararg comparators: Comparator) : Comparator {
+ 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(private val inner: Comparator) : Comparator> {
+ override fun compare(a: List, b: List): 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> = 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 private constructor() : Comparator {
+ 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 = BasicComparator()
+ /** A re-usable instance configured for [Album]s. */
+ val ALBUM: Comparator = BasicComparator()
+ /** A re-usable instance configured for [Artist]s. */
+ val ARTIST: Comparator = BasicComparator()
+ /** A re-usable instance configured for [Genre]s. */
+ val GENRE: Comparator = BasicComparator()
+ /** A re-usable instance configured for [Playlist]s. */
+ val PLAYLIST: Comparator = 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> private constructor() : Comparator {
+ 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()
+ /** A re-usable instance configured for [Long]s. */
+ val LONG = NullableComparator()
+ /** A re-usable instance configured for [Disc]s */
+ val DISC = NullableComparator()
+ /** A re-usable instance configured for [Date.Range]s. */
+ val DATE_RANGE = NullableComparator()
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt
index 63096dbf5..b9d77b0f8 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt
@@ -62,16 +62,16 @@ abstract class FlexibleListAdapter(
*
* @author Alexander Capehart (OxygenCobalt)
*/
-sealed class UpdateInstructions {
+sealed interface UpdateInstructions {
/** Use an asynchronous diff. Useful for unpredictable updates, but looks chaotic and janky. */
- object Diff : UpdateInstructions()
+ object Diff : UpdateInstructions
/**
* Visually replace all items from a given point. More visually coherent than [Diff].
*
* @param from The index at which to start replacing items (inclusive)
*/
- data class Replace(val from: Int) : UpdateInstructions()
+ data class Replace(val from: Int) : UpdateInstructions
/**
* Add a new set of items.
@@ -79,7 +79,7 @@ sealed class UpdateInstructions {
* @param at The position at which to add.
* @param size The amount of items to add.
*/
- data class Add(val at: Int, val size: Int) : UpdateInstructions()
+ data class Add(val at: Int, val size: Int) : UpdateInstructions
/**
* Move one item to another location.
@@ -87,14 +87,15 @@ sealed class UpdateInstructions {
* @param from The index of the item to move.
* @param to The index to move the item to.
*/
- data class Move(val from: Int, val to: Int) : UpdateInstructions()
+ data class Move(val from: Int, val to: Int) : UpdateInstructions
/**
* Remove an item.
*
* @param at The location that the item should be removed from.
+ * @param size The amount of items to add.
*/
- data class Remove(val at: Int) : UpdateInstructions()
+ data class Remove(val at: Int, val size: Int) : UpdateInstructions
}
/**
@@ -147,7 +148,7 @@ private class FlexibleListDiffer(
}
is UpdateInstructions.Remove -> {
currentList = newList
- updateCallback.onRemoved(instructions.at, 1)
+ updateCallback.onRemoved(instructions.at, instructions.size)
callback?.invoke()
}
is UpdateInstructions.Diff,
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt
index c84f3176e..1c5923b3c 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt
@@ -23,14 +23,12 @@ import android.util.AttributeSet
import android.view.WindowInsets
import androidx.annotation.AttrRes
import androidx.core.view.updatePadding
-import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* A [RecyclerView] with a few QoL extensions, such as:
* - Automatic edge-to-edge support
- * - Adapter-based [SpanSizeLookup] implementation
* - Automatic [setHasFixedSize] setup
*
* @author Alexander Capehart (OxygenCobalt)
@@ -47,7 +45,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Auxio's non-dialog RecyclerViews never change their size based on adapter contents,
// so we can enable fixed-size optimizations.
setHasFixedSize(true)
- addItemDecoration(HeaderItemDecoration(context))
}
final override fun setHasFixedSize(hasFixedSize: Boolean) {
@@ -65,36 +62,4 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
return insets
}
-
- override fun setAdapter(adapter: Adapter<*>?) {
- super.setAdapter(adapter)
-
- if (adapter is SpanSizeLookup) {
- // This adapter has support for special span sizes, hook it up to the
- // GridLayoutManager.
- val glm = (layoutManager as GridLayoutManager)
- val fullWidthSpanCount = glm.spanCount
- glm.spanSizeLookup =
- object : GridLayoutManager.SpanSizeLookup() {
- // Using the adapter implementation, if the adapter specifies that
- // an item is full width, it will take up all of the spans, using a
- // single span otherwise.
- override fun getSpanSize(position: Int) =
- if (adapter.isItemFullWidth(position)) fullWidthSpanCount else 1
- }
- }
- }
-
- /** A [RecyclerView.Adapter]-specific hook to control divider decoration visibility. */
-
- /** An [RecyclerView.Adapter]-specific hook to [GridLayoutManager.SpanSizeLookup]. */
- interface SpanSizeLookup {
- /**
- * Get if the item at a position takes up the whole width of the [RecyclerView] or not.
- *
- * @param position The position of the item.
- * @return true if the item is full-width, false otherwise.
- */
- fun isItemFullWidth(position: Int): Boolean
- }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt
deleted file mode 100644
index 1ffc6c1fc..000000000
--- a/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt
+++ /dev/null
@@ -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 .
- */
-
-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
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt
new file mode 100644
index 000000000..ea5629e78
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt
@@ -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 .
+ */
+
+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
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
index 1f5188c4f..0c9962996 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
@@ -20,12 +20,14 @@ package org.oxycblt.auxio.list.recycler
import android.view.View
import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.divider.MaterialDivider
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.BasicHeader
+import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
@@ -51,7 +53,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
fun bind(song: Song, listener: SelectableListListener) {
listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
- binding.songName.text = song.resolveName(binding.context)
+ binding.songName.text = song.name.resolve(binding.context)
binding.songInfo.text = song.artists.resolveNames(binding.context)
}
@@ -80,8 +82,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
val DIFF_CALLBACK =
object : SimpleDiffCallback() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
- oldItem.rawName == newItem.rawName &&
- oldItem.artists.areRawNamesTheSame(newItem.artists)
+ oldItem.name == newItem.name && oldItem.artists.areNamesTheSame(newItem.artists)
}
}
}
@@ -102,7 +103,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
fun bind(album: Album, listener: SelectableListListener) {
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
- binding.parentName.text = album.resolveName(binding.context)
+ binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text = album.artists.resolveNames(binding.context)
}
@@ -131,8 +132,8 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
val DIFF_CALLBACK =
object : SimpleDiffCallback() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
- oldItem.rawName == newItem.rawName &&
- oldItem.artists.areRawNamesTheSame(newItem.artists) &&
+ oldItem.name == newItem.name &&
+ oldItem.artists.areNamesTheSame(newItem.artists) &&
oldItem.releaseType == newItem.releaseType
}
}
@@ -154,17 +155,16 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
fun bind(artist: Artist, listener: SelectableListListener) {
listener.bind(artist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(artist)
- binding.parentName.text = artist.resolveName(binding.context)
+ binding.parentName.text = artist.name.resolve(binding.context)
binding.parentInfo.text =
- if (artist.songs.isNotEmpty()) {
- binding.context.getString(
- R.string.fmt_two,
- binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
- binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size))
- } else {
- // Artist has no songs, only display an album count.
- binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
- }
+ binding.context.getString(
+ R.string.fmt_two,
+ binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
+ if (artist.songs.isNotEmpty()) {
+ binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
+ } else {
+ binding.context.getString(R.string.def_song_count)
+ })
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@@ -193,7 +193,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
val DIFF_CALLBACK =
object : SimpleDiffCallback() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
- oldItem.rawName == newItem.rawName &&
+ oldItem.name == newItem.name &&
oldItem.albums.size == newItem.albums.size &&
oldItem.songs.size == newItem.songs.size
}
@@ -216,7 +216,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
fun bind(genre: Genre, listener: SelectableListListener) {
listener.bind(genre, this, menuButton = binding.parentMenu)
binding.parentImage.bind(genre)
- binding.parentName.text = genre.resolveName(binding.context)
+ binding.parentName.text = genre.name.resolve(binding.context)
binding.parentInfo.text =
binding.context.getString(
R.string.fmt_two,
@@ -248,8 +248,66 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback() {
- override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
- oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size
+ override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
+ oldItem.name == newItem.name &&
+ oldItem.artists.size == newItem.artists.size &&
+ oldItem.songs.size == newItem.songs.size
+ }
+ }
+}
+
+/**
+ * A [RecyclerView.ViewHolder] that displays a [Playlist]. Use [from] to create an instance.
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+class PlaylistViewHolder private constructor(private val binding: ItemParentBinding) :
+ SelectionIndicatorAdapter.ViewHolder(binding.root) {
+ /**
+ * Bind new data to this instance.
+ *
+ * @param playlist The new [Playlist] to bind.
+ * @param listener An [SelectableListListener] to bind interactions to.
+ */
+ fun bind(playlist: Playlist, listener: SelectableListListener) {
+ 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() {
+ override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist) =
+ oldItem.name == newItem.name && oldItem.songs.size == newItem.songs.size
}
}
}
@@ -287,10 +345,37 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback() {
- override fun areContentsTheSame(
- oldItem: BasicHeader,
- newItem: BasicHeader
- ): Boolean = oldItem.titleRes == newItem.titleRes
+ override fun areContentsTheSame(oldItem: BasicHeader, newItem: BasicHeader) =
+ oldItem.titleRes == newItem.titleRes
+ }
+ }
+}
+
+/**
+ * A [RecyclerView.ViewHolder] that displays a [Divider]. Use [from] to create an instance.
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+class DividerViewHolder private constructor(divider: MaterialDivider) :
+ RecyclerView.ViewHolder(divider) {
+
+ companion object {
+ /** Unique ID for this ViewHolder type. */
+ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DIVIDER
+
+ /**
+ * Create a new instance.
+ *
+ * @param parent The parent to inflate this instance from.
+ * @return A new instance.
+ */
+ fun from(parent: View) = DividerViewHolder(MaterialDivider(parent.context))
+
+ /** A comparator that can be used with DiffUtil. */
+ val DIFF_CALLBACK =
+ object : SimpleDiffCallback() {
+ override fun areContentsTheSame(oldItem: Divider, newItem: Divider) =
+ oldItem.anchor == newItem.anchor
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt
index a3012f56b..cb7bce063 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt
@@ -23,6 +23,7 @@ import android.view.MenuItem
import androidx.appcompat.widget.Toolbar
import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.R
+import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.showToast
@@ -35,22 +36,16 @@ import org.oxycblt.auxio.util.showToast
abstract class SelectionFragment :
ViewBindingFragment(), Toolbar.OnMenuItemClickListener {
protected abstract val selectionModel: SelectionViewModel
+ protected abstract val musicModel: MusicViewModel
protected abstract val playbackModel: PlaybackViewModel
- /**
- * Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed by
- * [SelectionFragment].
- *
- * @return The [SelectionToolbarOverlay] of the concrete [SelectionFragment]'s [VB], or null if
- * there is not one.
- */
- open fun getSelectionToolbar(binding: VB): SelectionToolbarOverlay? = null
+ open fun getSelectionToolbar(binding: VB): Toolbar? = null
override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
getSelectionToolbar(binding)?.apply {
// Add cancel and menu item listeners to manage what occurs with the selection.
- setOnSelectionCancelListener { selectionModel.consume() }
+ setNavigationOnClickListener { selectionModel.drop() }
setOnMenuItemClickListener(this@SelectionFragment)
}
}
@@ -63,21 +58,25 @@ abstract class SelectionFragment :
override fun onMenuItemClick(item: MenuItem) =
when (item.itemId) {
R.id.action_selection_play_next -> {
- playbackModel.playNext(selectionModel.consume())
+ playbackModel.playNext(selectionModel.take())
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_selection_queue_add -> {
- playbackModel.addToQueue(selectionModel.consume())
+ playbackModel.addToQueue(selectionModel.take())
requireContext().showToast(R.string.lng_queue_added)
true
}
+ R.id.action_selection_playlist_add -> {
+ musicModel.addToPlaylist(selectionModel.take())
+ true
+ }
R.id.action_selection_play -> {
- playbackModel.play(selectionModel.consume())
+ playbackModel.play(selectionModel.take())
true
}
R.id.action_selection_shuffle -> {
- playbackModel.shuffle(selectionModel.consume())
+ playbackModel.shuffle(selectionModel.take())
true
}
else -> false
diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt
deleted file mode 100644
index 05b203771..000000000
--- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt
+++ /dev/null
@@ -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 .
- */
-
-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
- }
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt
index 150e8552f..5329151ad 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt
@@ -24,7 +24,6 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.music.model.Library
/**
* A [ViewModel] that manages the current selection.
@@ -32,38 +31,42 @@ import org.oxycblt.auxio.music.model.Library
* @author Alexander Capehart (OxygenCobalt)
*/
@HiltViewModel
-class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) :
- ViewModel(), MusicRepository.Listener {
+class SelectionViewModel
+@Inject
+constructor(
+ private val musicRepository: MusicRepository,
+ private val musicSettings: MusicSettings
+) : ViewModel(), MusicRepository.UpdateListener {
private val _selected = MutableStateFlow(listOf())
/** the currently selected items. These are ordered in earliest selected and latest selected. */
val selected: StateFlow>
get() = _selected
init {
- musicRepository.addListener(this)
+ musicRepository.addUpdateListener(this)
}
- override fun onLibraryChanged(library: Library?) {
- if (library == null) {
- return
- }
-
+ override fun onMusicChanges(changes: MusicRepository.Changes) {
+ if (!changes.deviceLibrary) return
+ val deviceLibrary = musicRepository.deviceLibrary ?: return
+ val userLibrary = musicRepository.userLibrary ?: return
// Sanitize the selection to remove items that no longer exist and thus
// won't appear in any list.
_selected.value =
_selected.value.mapNotNull {
when (it) {
- is Song -> library.sanitize(it)
- is Album -> library.sanitize(it)
- is Artist -> library.sanitize(it)
- is Genre -> library.sanitize(it)
+ is Song -> deviceLibrary.findSong(it.uid)
+ is Album -> deviceLibrary.findAlbum(it.uid)
+ is Artist -> deviceLibrary.findArtist(it.uid)
+ is Genre -> deviceLibrary.findGenre(it.uid)
+ is Playlist -> userLibrary.findPlaylist(it.uid)
}
}
}
override fun onCleared() {
super.onCleared()
- musicRepository.removeListener(this)
+ musicRepository.removeUpdateListener(this)
}
/**
@@ -81,9 +84,27 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR
}
/**
- * Consume the current selection. This will clear any items that were selected prior.
+ * Clear the current selection and return it.
*
- * @return The list of selected items before it was cleared.
+ * @return A list of [Song]s collated from each item selected.
*/
- fun consume() = _selected.value.also { _selected.value = listOf() }
+ fun take() =
+ _selected.value
+ .flatMap {
+ when (it) {
+ is Song -> listOf(it)
+ is Album -> musicSettings.albumSongSort.songs(it.songs)
+ is Artist -> musicSettings.artistSongSort.songs(it.songs)
+ is Genre -> musicSettings.genreSongSort.songs(it.songs)
+ is Playlist -> it.songs
+ }
+ }
+ .also { drop() }
+
+ /**
+ * Clear the current selection.
+ *
+ * @return true if the prior selection was non-empty, false otherwise.
+ */
+ fun drop() = _selected.value.isNotEmpty().also { _selected.value = listOf() }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
new file mode 100644
index 000000000..545a5f234
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
@@ -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 .
+ */
+
+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"
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
index abeb521ed..bcf2fb53e 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
@@ -21,19 +21,19 @@ package org.oxycblt.auxio.music
import android.content.Context
import android.net.Uri
import android.os.Parcelable
+import androidx.room.TypeConverter
import java.security.MessageDigest
-import java.text.CollationKey
-import java.text.Collator
import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.list.Item
-import org.oxycblt.auxio.music.metadata.Date
-import org.oxycblt.auxio.music.metadata.Disc
-import org.oxycblt.auxio.music.metadata.ReleaseType
-import org.oxycblt.auxio.music.storage.MimeType
-import org.oxycblt.auxio.music.storage.Path
+import org.oxycblt.auxio.music.fs.MimeType
+import org.oxycblt.auxio.music.fs.Path
+import org.oxycblt.auxio.music.info.Date
+import org.oxycblt.auxio.music.info.Disc
+import org.oxycblt.auxio.music.info.Name
+import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.toUuidOrNull
@@ -51,35 +51,8 @@ sealed interface Music : Item {
*/
val uid: UID
- /**
- * The raw name of this item as it was extracted from the file-system. Will be null if the
- * item's name is unknown. When showing this item in a UI, avoid this in favor of [resolveName].
- */
- val rawName: String?
-
- /**
- * Returns a name suitable for use in the app UI. This should be favored over [rawName] in
- * nearly all cases.
- *
- * @param context [Context] required to obtain placeholder text or formatting information.
- * @return A human-readable string representing the name of this music. In the case that the
- * item does not have a name, an analogous "Unknown X" name is returned.
- */
- fun resolveName(context: Context): String
-
- /**
- * The raw sort name of this item as it was extracted from the file-system. This can be used not
- * only when sorting music, but also trying to locate music based on a fuzzy search by the user.
- * Will be null if the item has no known sort name.
- */
- val rawSortName: String?
-
- /**
- * A black-box value derived from [rawSortName] and [rawName] that can be used for user-friendly
- * sorting in the context of music. This should be preferred over [rawSortName] in most cases.
- * Null if there are no [rawName] or [rawSortName] values to build on.
- */
- val sortName: SortName?
+ /** The [Name] of the music item. */
+ val name: Name
/**
* A unique identifier for a piece of music.
@@ -136,7 +109,25 @@ sealed interface Music : Item {
MUSICBRAINZ("org.musicbrainz")
}
+ object TypeConverters {
+ /** @see [Music.UID.toString] */
+ @TypeConverter fun fromMusicUID(uid: UID?) = uid?.toString()
+
+ /** @see [Music.UID.fromString] */
+ @TypeConverter fun toMusicUid(string: String?) = string?.let(UID::fromString)
+ }
+
companion object {
+ /**
+ * Creates an Auxio-style [UID] of random composition. Used if there is no
+ * non-subjective, unlikely-to-change metadata of the music.
+ *
+ * @param mode The analogous [MusicMode] of the item that created this [UID].
+ */
+ fun auxio(mode: MusicMode): UID {
+ return UID(Format.AUXIO, mode, UUID.randomUUID())
+ }
+
/**
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
* unlikely-to-change metadata of the music.
@@ -189,7 +180,7 @@ sealed interface Music : Item {
* file.
* @return A new MusicBrainz-style [UID].
*/
- fun musicBrainz(mode: MusicMode, mbid: UUID): UID = UID(Format.MUSICBRAINZ, mode, mbid)
+ fun musicBrainz(mode: MusicMode, mbid: UUID) = UID(Format.MUSICBRAINZ, mode, mbid)
/**
* Convert a [UID]'s string representation back into a concrete [UID] instance.
@@ -357,83 +348,39 @@ interface Genre : MusicParent {
}
/**
- * A black-box datatype for a variation of music names that is suitable for music-oriented sorting.
- * It will automatically handle articles like "The" and numeric components like "An".
+ * A playlist.
*
* @author Alexander Capehart (OxygenCobalt)
*/
-class SortName(name: String, musicSettings: MusicSettings) : Comparable {
- private val collationKey: CollationKey
- val thumbString: String?
-
- init {
- var sortName = name
- if (musicSettings.intelligentSorting) {
- sortName = sortName.replace(LEADING_PUNCTUATION_REGEX, "")
-
- sortName =
- sortName.run {
- when {
- length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
- length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
- length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
- else -> this
- }
- }
-
- // Zero pad all numbers to six digits for better sorting
- sortName = sortName.replace(CONSECUTIVE_DIGITS_REGEX) { it.value.padStart(6, '0') }
- }
-
- collationKey = COLLATOR.getCollationKey(sortName)
-
- // Keep track of a string to use in the thumb view.
- // Simply show '#' for everything before 'A'
- // TODO: This needs to be moved elsewhere.
- thumbString =
- collationKey?.run {
- val thumbChar = sourceString.firstOrNull()
- if (thumbChar?.isLetter() == true) thumbChar.uppercase() else "#"
- }
- }
-
- override fun toString(): String = collationKey.sourceString
-
- override fun compareTo(other: SortName) = collationKey.compareTo(other.collationKey)
-
- override fun equals(other: Any?) = other is SortName && collationKey == other.collationKey
-
- override fun hashCode(): Int = collationKey.hashCode()
-
- private companion object {
- val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
- val LEADING_PUNCTUATION_REGEX = Regex("[\\p{Punct}+]")
- val CONSECUTIVE_DIGITS_REGEX = Regex("\\d+")
- }
+interface Playlist : MusicParent {
+ /** The albums indirectly linked to by the [Song]s of this [Playlist]. */
+ val albums: List
+ /** The total duration of the songs in this genre, in milliseconds. */
+ val durationMs: Long
}
/**
- * Run [Music.resolveName] on each instance in the given list and concatenate them into a [String]
- * in a localized manner.
+ * Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
+ * localized manner.
*
* @param context [Context] required
* @return A concatenated string.
*/
fun List.resolveNames(context: Context) =
- concatLocalized(context) { it.resolveName(context) }
+ concatLocalized(context) { it.name.resolve(context) }
/**
- * Returns if [Music.rawName] matches for each item in a list. Useful for scenarios where the
- * display information of an item must be compared without a context.
+ * Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
+ * information of an item must be compared without a context.
*
* @param other The list of items to compare to.
- * @return True if they are the same (by [Music.rawName]), false otherwise.
+ * @return True if they are the same (by [Music.name]), false otherwise.
*/
-fun List.areRawNamesTheSame(other: List): Boolean {
+fun List.areNamesTheSame(other: List): Boolean {
for (i in 0 until max(size, other.size)) {
val a = getOrNull(i) ?: return false
val b = other.getOrNull(i) ?: return false
- if (a.rawName != b.rawName) {
+ if (a.name != b.name) {
return false
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt
index f959e5f7d..03ec48dae 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt
@@ -33,7 +33,9 @@ enum class MusicMode {
/** Configure with respect to [Artist] instances. */
ARTISTS,
/** Configure with respect to [Genre] instances. */
- GENRES;
+ GENRES,
+ /** Configure with respect to [Playlist] instances. */
+ PLAYLISTS;
/**
* The integer representation of this instance.
@@ -47,6 +49,7 @@ enum class MusicMode {
ALBUMS -> IntegerTable.MUSIC_MODE_ALBUMS
ARTISTS -> IntegerTable.MUSIC_MODE_ARTISTS
GENRES -> IntegerTable.MUSIC_MODE_GENRES
+ PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS
}
companion object {
@@ -63,6 +66,7 @@ enum class MusicMode {
IntegerTable.MUSIC_MODE_ALBUMS -> ALBUMS
IntegerTable.MUSIC_MODE_ARTISTS -> ARTISTS
IntegerTable.MUSIC_MODE_GENRES -> GENRES
+ IntegerTable.MUSIC_MODE_PLAYLISTS -> PLAYLISTS
else -> null
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt
index 58fd9b323..e875dd04b 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt
@@ -23,13 +23,10 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
-import org.oxycblt.auxio.music.system.Indexer
-import org.oxycblt.auxio.music.system.IndexerImpl
@Module
@InstallIn(SingletonComponent::class)
interface MusicModule {
@Singleton @Binds fun repository(musicRepository: MusicRepositoryImpl): MusicRepository
- @Singleton @Binds fun indexer(indexer: IndexerImpl): Indexer
@Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
index 461cb6401..6fa0b4f79 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
@@ -18,75 +18,452 @@
package org.oxycblt.auxio.music
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
+import java.util.*
import javax.inject.Inject
-import org.oxycblt.auxio.music.model.Library
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+import org.oxycblt.auxio.music.cache.CacheRepository
+import org.oxycblt.auxio.music.device.DeviceLibrary
+import org.oxycblt.auxio.music.device.RawSong
+import org.oxycblt.auxio.music.fs.MediaStoreExtractor
+import org.oxycblt.auxio.music.metadata.TagExtractor
+import org.oxycblt.auxio.music.user.MutableUserLibrary
+import org.oxycblt.auxio.music.user.UserLibrary
+import org.oxycblt.auxio.util.logD
+import org.oxycblt.auxio.util.logE
+import org.oxycblt.auxio.util.logW
/**
- * A repository granting access to the music library.
+ * Primary manager of music information and loading.
*
- * This can be used to obtain certain music items, or await changes to the music library. It is
- * generally recommended to use this over Indexer to keep track of the library state, as the
- * interface will be less volatile.
+ * Music information is loaded in-memory by this repository using an [IndexingWorker]. Changes in
+ * music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface MusicRepository {
- /**
- * The current [Library]. May be null if a [Library] has not been successfully loaded yet. This
- * can change, so it's highly recommended to not access this directly and instead rely on
- * [Listener].
- */
- var library: Library?
+ /** The current music information found on the device. */
+ val deviceLibrary: DeviceLibrary?
+ /** The current user-defined music information. */
+ val userLibrary: UserLibrary?
+ /** The current state of music loading. Null if no load has occurred yet. */
+ val indexingState: IndexingState?
/**
- * Add a [Listener] to this instance. This can be used to receive changes in the music library.
- * Will invoke all [Listener] methods to initialize the instance with the current state.
+ * Add an [UpdateListener] to receive updates from this instance.
*
- * @param listener The [Listener] to add.
- * @see Listener
+ * @param listener The [UpdateListener] to add.
*/
- fun addListener(listener: Listener)
+ fun addUpdateListener(listener: UpdateListener)
/**
- * Remove a [Listener] from this instance, preventing it from receiving any further updates.
+ * Remove an [UpdateListener] such that it does not receive any further updates from this
+ * instance.
*
- * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
- * the first place.
- * @see Listener
+ * @param listener The [UpdateListener] to remove.
*/
- fun removeListener(listener: Listener)
+ fun removeUpdateListener(listener: UpdateListener)
- /** A listener for changes in [MusicRepository] */
- interface Listener {
+ /**
+ * Add an [IndexingListener] to receive updates from this instance.
+ *
+ * @param listener The [UpdateListener] to add.
+ */
+ fun addIndexingListener(listener: IndexingListener)
+
+ /**
+ * Remove an [IndexingListener] such that it does not receive any further updates from this
+ * instance.
+ *
+ * @param listener The [IndexingListener] to remove.
+ */
+ fun removeIndexingListener(listener: IndexingListener)
+
+ /**
+ * Register an [IndexingWorker] to handle loading operations. Will do nothing if one is already
+ * registered.
+ *
+ * @param worker The [IndexingWorker] to register.
+ */
+ fun registerWorker(worker: IndexingWorker)
+
+ /**
+ * Unregister an [IndexingWorker] and drop any work currently being done by it. Does nothing if
+ * given [IndexingWorker] is not the currently registered instance.
+ *
+ * @param worker The [IndexingWorker] to unregister.
+ */
+ fun unregisterWorker(worker: IndexingWorker)
+
+ /**
+ * Generically search for the [Music] associated with the given [Music.UID]. Note that this
+ * method is much slower that type-specific find implementations, so this should only be used if
+ * the type of music being searched for is entirely unknown.
+ *
+ * @param uid The [Music.UID] to search for.
+ * @return The expected [Music] information, or null if it could not be found.
+ */
+ fun find(uid: Music.UID): Music?
+
+ /**
+ * Create a new [Playlist] of the given [Song]s.
+ *
+ * @param name The name of the new [Playlist].
+ * @param songs The songs to populate the new [Playlist] with.
+ */
+ suspend fun createPlaylist(name: String, songs: List)
+
+ /**
+ * 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, 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)
+
+ /**
+ * Request that a music loading operation is started by the current [IndexingWorker]. Does
+ * nothing if one is not available.
+ *
+ * @param withCache Whether to load with the music cache or not.
+ */
+ fun requestIndex(withCache: Boolean)
+
+ /**
+ * Load the music library. Any prior loads will be canceled.
+ *
+ * @param worker The [IndexingWorker] to perform the work with.
+ * @param withCache Whether to load with the music cache or not.
+ * @return The top-level music loading [Job] started.
+ */
+ fun index(worker: IndexingWorker, withCache: Boolean): Job
+
+ /** A listener for changes to the stored music information. */
+ interface UpdateListener {
/**
- * Called when the current [Library] has changed.
+ * Called when a change to the stored music information occurs.
*
- * @param library The new [Library], or null if no [Library] has been loaded yet.
+ * @param changes The [Changes] that have occurred.
*/
- fun onLibraryChanged(library: Library?)
+ fun onMusicChanges(changes: Changes)
+ }
+
+ /**
+ * Flags indicating which kinds of music information changed.
+ *
+ * @param deviceLibrary Whether the current [DeviceLibrary] has changed.
+ * @param userLibrary Whether the current [Playlist]s have changed.
+ */
+ data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
+
+ /** A listener for events in the music loading process. */
+ interface IndexingListener {
+ /** Called when the music loading state changed. */
+ fun onIndexingStateChanged()
+ }
+
+ /** A persistent worker that can load music in the background. */
+ interface IndexingWorker {
+ /** A [Context] required to read device storage */
+ val context: Context
+
+ /** The [CoroutineScope] to perform coroutine music loading work on. */
+ val scope: CoroutineScope
+
+ /**
+ * Request that the music loading process ([index]) should be started. Any prior loads
+ * should be canceled.
+ *
+ * @param withCache Whether to use the music cache when loading.
+ */
+ fun requestIndex(withCache: Boolean)
}
}
-class MusicRepositoryImpl @Inject constructor() : MusicRepository {
- private val listeners = mutableListOf()
+class MusicRepositoryImpl
+@Inject
+constructor(
+ private val cacheRepository: CacheRepository,
+ private val mediaStoreExtractor: MediaStoreExtractor,
+ private val tagExtractor: TagExtractor,
+ private val deviceLibraryFactory: DeviceLibrary.Factory,
+ private val userLibraryFactory: UserLibrary.Factory
+) : MusicRepository {
+ private val updateListeners = mutableListOf()
+ private val indexingListeners = mutableListOf()
+ @Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null
- @Volatile
- override var library: Library? = null
- set(value) {
- field = value
- for (callback in listeners) {
- callback.onLibraryChanged(library)
+ @Volatile override var deviceLibrary: DeviceLibrary? = null
+ @Volatile override var userLibrary: MutableUserLibrary? = null
+ @Volatile private var previousCompletedState: IndexingState.Completed? = null
+ @Volatile private var currentIndexingState: IndexingState? = null
+ override val indexingState: IndexingState?
+ get() = currentIndexingState ?: previousCompletedState
+
+ @Synchronized
+ override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
+ updateListeners.add(listener)
+ listener.onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = true))
+ }
+
+ @Synchronized
+ override fun removeUpdateListener(listener: MusicRepository.UpdateListener) {
+ updateListeners.remove(listener)
+ }
+
+ @Synchronized
+ override fun addIndexingListener(listener: MusicRepository.IndexingListener) {
+ indexingListeners.add(listener)
+ listener.onIndexingStateChanged()
+ }
+
+ @Synchronized
+ override fun removeIndexingListener(listener: MusicRepository.IndexingListener) {
+ indexingListeners.remove(listener)
+ }
+
+ @Synchronized
+ override fun registerWorker(worker: MusicRepository.IndexingWorker) {
+ if (indexingWorker != null) {
+ logW("Worker is already registered")
+ return
+ }
+ indexingWorker = worker
+ if (indexingState == null) {
+ worker.requestIndex(true)
+ }
+ }
+
+ @Synchronized
+ override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
+ if (indexingWorker !== worker) {
+ logW("Given worker did not match current worker")
+ return
+ }
+ indexingWorker = null
+ currentIndexingState = null
+ }
+
+ @Synchronized
+ override fun find(uid: Music.UID) =
+ (deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
+ ?: userLibrary?.findPlaylist(uid))
+
+ override suspend fun createPlaylist(name: String, songs: List