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.

- - Latest Version + + Latest Version Releases @@ -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) { + val userLibrary = synchronized(this) { userLibrary ?: return } + userLibrary.createPlaylist(name, songs) + notifyUserLibraryChange() + } + + override suspend fun renamePlaylist(playlist: Playlist, name: String) { + val userLibrary = synchronized(this) { userLibrary ?: return } + userLibrary.renamePlaylist(playlist, name) + notifyUserLibraryChange() + } + + override suspend fun deletePlaylist(playlist: Playlist) { + val userLibrary = synchronized(this) { userLibrary ?: return } + userLibrary.deletePlaylist(playlist) + notifyUserLibraryChange() + } + + override suspend fun addToPlaylist(songs: List, playlist: Playlist) { + val userLibrary = synchronized(this) { userLibrary ?: return } + userLibrary.addToPlaylist(playlist, songs) + notifyUserLibraryChange() + } + + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { + val userLibrary = synchronized(this) { userLibrary ?: return } + userLibrary.rewritePlaylist(playlist, songs) + notifyUserLibraryChange() + } + + @Synchronized + private fun notifyUserLibraryChange() { + for (listener in updateListeners) { + listener.onMusicChanges( + MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) + } + } + + @Synchronized + override fun requestIndex(withCache: Boolean) { + indexingWorker?.requestIndex(withCache) + } + + override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) = + worker.scope.launch { + try { + val start = System.currentTimeMillis() + indexImpl(worker, withCache) + logD( + "Music indexing completed successfully in " + + "${System.currentTimeMillis() - start}ms") + } catch (e: CancellationException) { + // Got cancelled, propagate upwards to top-level co-routine. + logD("Loading routine was cancelled") + throw e + } catch (e: Exception) { + // Music loading process failed due to something we have not handled. + logE("Music indexing failed") + logE(e.stackTraceToString()) + emitComplete(e) } } - @Synchronized - override fun addListener(listener: MusicRepository.Listener) { - listener.onLibraryChanged(library) - listeners.add(listener) + private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) { + if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) == + PackageManager.PERMISSION_DENIED) { + logE("Permission check failed") + // No permissions, signal that we can't do anything. + throw NoAudioPermissionException() + } + + // Start initializing the extractors. Use an indeterminate state, as there is no ETA on + // how long a media database query will take. + emitLoading(IndexingProgress.Indeterminate) + + // Do the initial query of the cache and media databases in parallel. + logD("Starting queries") + val mediaStoreQueryJob = worker.scope.tryAsync { mediaStoreExtractor.query() } + val cache = + if (withCache) { + cacheRepository.readCache() + } else { + null + } + val query = mediaStoreQueryJob.await().getOrThrow() + + // Now start processing the queried song information in parallel. Songs that can't be + // received from the cache are consisted incomplete and pushed to a separate channel + // that will eventually be processed into completed raw songs. + logD("Starting song discovery") + val completeSongs = Channel(Channel.UNLIMITED) + val incompleteSongs = Channel(Channel.UNLIMITED) + val mediaStoreJob = + worker.scope.tryAsync { + mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) + incompleteSongs.close() + } + val metadataJob = + worker.scope.tryAsync { + tagExtractor.consume(incompleteSongs, completeSongs) + completeSongs.close() + } + + // Await completed raw songs as they are processed. + val rawSongs = LinkedList() + for (rawSong in completeSongs) { + rawSongs.add(rawSong) + emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) + } + // These should be no-ops + mediaStoreJob.await().getOrThrow() + metadataJob.await().getOrThrow() + + if (rawSongs.isEmpty()) { + logE("Music library was empty") + throw NoMusicException() + } + + // Successfully loaded the library, now save the cache, create the library, and + // read playlist information in parallel. + logD("Discovered ${rawSongs.size} songs, starting finalization") + // TODO: Indicate playlist state in loading process? + emitLoading(IndexingProgress.Indeterminate) + val deviceLibraryChannel = Channel() + val deviceLibraryJob = + worker.scope.tryAsync(Dispatchers.Main) { + deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) } + } + val userLibraryJob = + worker.scope.tryAsync { + userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() } + } + if (cache == null || cache.invalidated) { + cacheRepository.writeCache(rawSongs) + } + val deviceLibrary = deviceLibraryJob.await().getOrThrow() + val userLibrary = userLibraryJob.await().getOrThrow() + withContext(Dispatchers.Main) { + emitComplete(null) + emitData(deviceLibrary, userLibrary) + } + } + + private inline fun CoroutineScope.tryAsync( + context: CoroutineContext = EmptyCoroutineContext, + crossinline block: suspend () -> R + ) = + async(context) { + try { + Result.success(block()) + } catch (e: Exception) { + Result.failure(e) + } + } + + private suspend fun emitLoading(progress: IndexingProgress) { + yield() + synchronized(this) { + currentIndexingState = IndexingState.Indexing(progress) + for (listener in indexingListeners) { + listener.onIndexingStateChanged() + } + } + } + + private suspend fun emitComplete(error: Exception?) { + yield() + synchronized(this) { + previousCompletedState = IndexingState.Completed(error) + currentIndexingState = null + for (listener in indexingListeners) { + listener.onIndexingStateChanged() + } + } } @Synchronized - override fun removeListener(listener: MusicRepository.Listener) { - listeners.remove(listener) + private fun emitData(deviceLibrary: DeviceLibrary, userLibrary: MutableUserLibrary) { + val deviceLibraryChanged = this.deviceLibrary != deviceLibrary + val userLibraryChanged = this.userLibrary != userLibrary + if (!deviceLibraryChanged && !userLibraryChanged) return + + this.deviceLibrary = deviceLibrary + this.userLibrary = userLibrary + val changes = MusicRepository.Changes(deviceLibraryChanged, userLibraryChanged) + for (listener in updateListeners) { + listener.onMusicChanges(changes) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index 7f986fdbf..48b180388 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -25,8 +25,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.music.storage.Directory -import org.oxycblt.auxio.music.storage.MusicDirectories +import org.oxycblt.auxio.music.fs.Directory +import org.oxycblt.auxio.music.fs.MusicDirectories import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.getSystemServiceCompat @@ -55,11 +55,13 @@ interface MusicSettings : Settings { var artistSort: Sort /** The [Sort] mode used in [Genre] lists. */ var genreSort: Sort + /** The [Sort] mode used in [Playlist] lists. */ + var playlistSort: Sort /** The [Sort] mode used in an [Album]'s [Song] list. */ var albumSongSort: Sort /** The [Sort] mode used in an [Artist]'s [Song] list. */ var artistSongSort: Sort - /** The [Sort] mode used in an [Genre]'s [Song] list. */ + /** The [Sort] mode used in a [Genre]'s [Song] list. */ var genreSongSort: Sort interface Listener { @@ -162,6 +164,17 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context } } + override var playlistSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_playlists_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_playlists_sort), value.intCode) + apply() + } + } override var albumSongSort: Sort get() { var sort = diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 9b99c7f2e..d207bd135 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -19,11 +19,15 @@ package org.oxycblt.auxio.music import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.music.system.Indexer +import kotlinx.coroutines.launch +import org.oxycblt.auxio.util.Event +import org.oxycblt.auxio.util.MutableEvent /** * A [ViewModel] providing data specific to the music loading process. @@ -31,49 +35,171 @@ import org.oxycblt.auxio.music.system.Indexer * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel -class MusicViewModel @Inject constructor(private val indexer: Indexer) : - ViewModel(), Indexer.Listener { +class MusicViewModel +@Inject +constructor( + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings +) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { - private val _indexerState = MutableStateFlow(null) + private val _indexingState = MutableStateFlow(null) /** The current music loading state, or null if no loading is going on. */ - val indexerState: StateFlow = _indexerState + val indexingState: StateFlow = _indexingState private val _statistics = MutableStateFlow(null) /** [Statistics] about the last completed music load. */ val statistics: StateFlow get() = _statistics + private val _newPlaylistSongs = MutableEvent>() + /** Flag for opening a dialog to create a playlist of the given [Song]s. */ + val newPlaylistSongs: Event> = _newPlaylistSongs + + private val _playlistToRename = MutableEvent() + /** Flag for opening a dialog to rename the given [Playlist]. */ + val playlistToRename: Event + get() = _playlistToRename + + private val _playlistToDelete = MutableEvent() + /** Flag for opening a dialog to confirm deletion of the given [Playlist]. */ + val playlistToDelete: Event + get() = _playlistToDelete + + private val _songsToAdd = MutableEvent>() + /** Flag for opening a dialog to add the given [Song]s to a playlist. */ + val songsToAdd: Event> = _songsToAdd + init { - indexer.registerListener(this) + musicRepository.addUpdateListener(this) + musicRepository.addIndexingListener(this) } override fun onCleared() { - indexer.unregisterListener(this) + musicRepository.removeUpdateListener(this) + musicRepository.removeIndexingListener(this) } - override fun onIndexerStateChanged(state: Indexer.State?) { - _indexerState.value = state - if (state is Indexer.State.Complete) { - // New state is a completed library, update the statistics values. - val library = state.result.getOrNull() ?: return - _statistics.value = - Statistics( - library.songs.size, - library.albums.size, - library.artists.size, - library.genres.size, - library.songs.sumOf { it.durationMs }) - } + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.deviceLibrary) return + val deviceLibrary = musicRepository.deviceLibrary ?: return + _statistics.value = + Statistics( + deviceLibrary.songs.size, + deviceLibrary.albums.size, + deviceLibrary.artists.size, + deviceLibrary.genres.size, + deviceLibrary.songs.sumOf { it.durationMs }) + } + + override fun onIndexingStateChanged() { + _indexingState.value = musicRepository.indexingState } /** Requests that the music library should be re-loaded while leveraging the cache. */ fun refresh() { - indexer.requestReindex(true) + musicRepository.requestIndex(true) } /** Requests that the music library be re-loaded without the cache. */ fun rescan() { - indexer.requestReindex(false) + musicRepository.requestIndex(false) + } + + /** + * Create a new generic [Playlist]. + * + * @param name The name of the new [Playlist]. If null, the user will be prompted for one. + * @param songs The [Song]s to be contained in the new playlist. + */ + fun createPlaylist(name: String? = null, songs: List = listOf()) { + if (name != null) { + viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) } + } else { + _newPlaylistSongs.put(songs) + } + } + + /** + * Rename the given playlist. + * + * @param playlist The [Playlist] to rename, + * @param name The new name of the [Playlist]. If null, the user will be prompted for a name. + */ + fun renamePlaylist(playlist: Playlist, name: String? = null) { + if (name != null) { + viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) } + } else { + _playlistToRename.put(playlist) + } + } + + /** + * Delete a [Playlist]. + * + * @param playlist The playlist to delete. + * @param rude Whether to immediately delete the playlist or prompt the user first. This should + * be false at almost all times. + */ + fun deletePlaylist(playlist: Playlist, rude: Boolean = false) { + if (rude) { + viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) } + } else { + _playlistToDelete.put(playlist) + } + } + + /** + * Add a [Song] to a [Playlist]. + * + * @param song The [Song] to add to the [Playlist]. + * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. + */ + fun addToPlaylist(song: Song, playlist: Playlist? = null) { + addToPlaylist(listOf(song), playlist) + } + + /** + * Add an [Album] to a [Playlist]. + * + * @param album The [Album] to add to the [Playlist]. + * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. + */ + fun addToPlaylist(album: Album, playlist: Playlist? = null) { + addToPlaylist(musicSettings.albumSongSort.songs(album.songs), playlist) + } + + /** + * Add an [Artist] to a [Playlist]. + * + * @param artist The [Artist] to add to the [Playlist]. + * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. + */ + fun addToPlaylist(artist: Artist, playlist: Playlist? = null) { + addToPlaylist(musicSettings.artistSongSort.songs(artist.songs), playlist) + } + + /** + * Add a [Genre] to a [Playlist]. + * + * @param genre The [Genre] to add to the [Playlist]. + * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. + */ + fun addToPlaylist(genre: Genre, playlist: Playlist? = null) { + addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist) + } + + /** + * Add [Song]s to a [Playlist]. + * + * @param songs The [Song]s to add to the [Playlist]. + * @param playlist The [Playlist] to add to. If null, the user will be prompted for one. + */ + fun addToPlaylist(songs: List, playlist: Playlist? = null) { + if (playlist != null) { + viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) } + } else { + _songsToAdd.put(songs) + } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 8e9830aba..2cf6d33c0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -27,10 +27,10 @@ import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.TypeConverter import androidx.room.TypeConverters -import org.oxycblt.auxio.music.metadata.Date +import org.oxycblt.auxio.music.device.RawSong +import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -import org.oxycblt.auxio.music.model.RawSong @Database(entities = [CachedSong::class], version = 27, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt index 55a58ba74..967d53d68 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt @@ -19,7 +19,7 @@ package org.oxycblt.auxio.music.cache import javax.inject.Inject -import org.oxycblt.auxio.music.model.RawSong +import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.util.* /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/model/Library.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt similarity index 73% rename from app/src/main/java/org/oxycblt/auxio/music/model/Library.kt rename to app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 4f3b217c8..af42d85ab 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/model/Library.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * Library.kt is part of Auxio. + * DeviceLibrary.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,60 +16,44 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.model +package org.oxycblt.auxio.music.device import android.content.Context import android.net.Uri import android.provider.OpenableColumns +import javax.inject.Inject import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.storage.contentResolverSafe -import org.oxycblt.auxio.music.storage.useQuery +import org.oxycblt.auxio.music.fs.contentResolverSafe +import org.oxycblt.auxio.music.fs.useQuery import org.oxycblt.auxio.util.logD /** - * Organized music library information. + * Organized music library information obtained from device storage. * * This class allows for the creation of a well-formed music library graph from raw song - * information. It's generally not expected to create this yourself and instead use - * [MusicRepository]. + * information. Instances are immutable. It's generally not expected to create this yourself and + * instead use [MusicRepository]. * * @author Alexander Capehart */ -interface Library { - /** All [Song]s in this [Library]. */ +interface DeviceLibrary { + /** All [Song]s in this [DeviceLibrary]. */ val songs: List - /** All [Album]s in this [Library]. */ + /** All [Album]s in this [DeviceLibrary]. */ val albums: List - /** All [Artist]s in this [Library]. */ + /** All [Artist]s in this [DeviceLibrary]. */ val artists: List - /** All [Genre]s in this [Library]. */ + /** All [Genre]s in this [DeviceLibrary]. */ val genres: List /** - * Finds a [Music] item [T] in the library by it's [Music.UID]. + * Find a [Song] instance corresponding to the given [Music.UID]. * * @param uid The [Music.UID] to search for. - * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or - * the [Music.UID] did not correspond to a [T]. + * @return The corresponding [Song], or null if one was not found. */ - fun find(uid: Music.UID): T? - - /** - * Convert a [Song] from an another library into a [Song] in this [Library]. - * - * @param song The [Song] to convert. - * @return The analogous [Song] in this [Library], or null if it does not exist. - */ - fun sanitize(song: Song): Song? - - /** - * Convert a [MusicParent] from an another library into a [MusicParent] in this [Library]. - * - * @param parent The [MusicParent] to convert. - * @return The analogous [Album] in this [Library], or null if it does not exist. - */ - fun sanitize(parent: T): T? + fun findSong(uid: Music.UID): Song? /** * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri]. @@ -80,34 +64,72 @@ interface Library { */ fun findSongForUri(context: Context, uri: Uri): Song? + /** + * Find a [Album] instance corresponding to the given [Music.UID]. + * + * @param uid The [Music.UID] to search for. + * @return The corresponding [Song], or null if one was not found. + */ + fun findAlbum(uid: Music.UID): Album? + + /** + * Find a [Artist] instance corresponding to the given [Music.UID]. + * + * @param uid The [Music.UID] to search for. + * @return The corresponding [Song], or null if one was not found. + */ + fun findArtist(uid: Music.UID): Artist? + + /** + * Find a [Genre] instance corresponding to the given [Music.UID]. + * + * @param uid The [Music.UID] to search for. + * @return The corresponding [Song], or null if one was not found. + */ + fun findGenre(uid: Music.UID): Genre? + + /** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */ + interface Factory { + /** + * Create a new [DeviceLibrary]. + * + * @param rawSongs [RawSong] instances to create a [DeviceLibrary] from. + */ + suspend fun create(rawSongs: List): DeviceLibrary + } + companion object { /** - * Create an instance of [Library]. + * Create an instance of [DeviceLibrary]. * * @param rawSongs [RawSong]s to create the library out of. * @param settings [MusicSettings] required. */ - fun from(rawSongs: List, settings: MusicSettings): Library = - LibraryImpl(rawSongs, settings) + fun from(rawSongs: List, settings: MusicSettings): DeviceLibrary = + DeviceLibraryImpl(rawSongs, settings) } } -private class LibraryImpl(rawSongs: List, settings: MusicSettings) : Library { +class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) : + DeviceLibrary.Factory { + override suspend fun create(rawSongs: List): DeviceLibrary = + DeviceLibraryImpl(rawSongs, musicSettings) +} + +private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings) : DeviceLibrary { override val songs = buildSongs(rawSongs, settings) override val albums = buildAlbums(songs, settings) override val artists = buildArtists(songs, albums, settings) override val genres = buildGenres(songs, settings) // Use a mapping to make finding information based on it's UID much faster. - private val uidMap = buildMap { - songs.forEach { put(it.uid, it.finalize()) } - albums.forEach { put(it.uid, it.finalize()) } - artists.forEach { put(it.uid, it.finalize()) } - genres.forEach { put(it.uid, it.finalize()) } - } + private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } } + private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } } + private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } } + private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } } override fun equals(other: Any?) = - other is Library && + other is DeviceLibrary && other.songs == songs && other.albums == albums && other.artists == artists && @@ -121,18 +143,10 @@ private class LibraryImpl(rawSongs: List, settings: MusicSettings) : Li return hashCode } - /** - * Finds a [Music] item [T] in the library by it's [Music.UID]. - * - * @param uid The [Music.UID] to search for. - * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or - * the [Music.UID] did not correspond to a [T]. - */ - @Suppress("UNCHECKED_CAST") override fun find(uid: Music.UID) = uidMap[uid] as? T - - override fun sanitize(song: Song) = find(song.uid) - - override fun sanitize(parent: T) = find(parent.uid) + override fun findSong(uid: Music.UID) = songUidMap[uid] + override fun findAlbum(uid: Music.UID) = albumUidMap[uid] + override fun findArtist(uid: Music.UID) = artistUidMap[uid] + override fun findGenre(uid: Music.UID) = genreUidMap[uid] override fun findSongForUri(context: Context, uri: Uri) = context.contentResolverSafe.useQuery( @@ -156,7 +170,7 @@ private class LibraryImpl(rawSongs: List, settings: MusicSettings) : Li */ private fun buildSongs(rawSongs: List, settings: MusicSettings) = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - .songs(rawSongs.map { SongImpl(it, settings) }.distinct()) + .songs(rawSongs.map { SongImpl(it, settings) }.distinctBy { it.uid }) /** * Build a list of [Album]s from the given [Song]s. diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt similarity index 65% rename from app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt rename to app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt index cf42234f4..41b69a498 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * Separators.kt is part of Auxio. + * DeviceModule.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,17 +16,15 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.metadata +package org.oxycblt.auxio.music.device -/** - * Defines the allowed separator characters that can be used to delimit multi-value tags. - * - * @author Alexander Capehart (OxygenCobalt) - */ -object Separators { - const val COMMA = ',' - const val SEMICOLON = ';' - const val SLASH = '/' - const val PLUS = '+' - const val AND = '&' +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DeviceModule { + @Binds fun deviceLibraryProvider(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory } diff --git a/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt similarity index 80% rename from app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt rename to app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 682c011fe..c98049d68 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * MusicImpl.kt is part of Auxio. + * DeviceMusicImpl.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,26 +16,23 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.model +package org.oxycblt.auxio.music.device -import android.content.Context -import androidx.annotation.VisibleForTesting -import java.security.MessageDigest import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.metadata.Date -import org.oxycblt.auxio.music.metadata.Disc -import org.oxycblt.auxio.music.metadata.ReleaseType +import org.oxycblt.auxio.music.fs.MimeType +import org.oxycblt.auxio.music.fs.Path +import org.oxycblt.auxio.music.fs.toAudioUri +import org.oxycblt.auxio.music.fs.toCoverUri +import org.oxycblt.auxio.music.info.* +import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.parseId3GenreNames import org.oxycblt.auxio.music.metadata.parseMultiValue -import org.oxycblt.auxio.music.storage.MimeType -import org.oxycblt.auxio.music.storage.Path -import org.oxycblt.auxio.music.storage.toAudioUri -import org.oxycblt.auxio.music.storage.toCoverUri import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.unlikelyToBeNull +import org.oxycblt.auxio.util.update /** * Library-backed implementation of [Song]. @@ -44,7 +41,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @param musicSettings [MusicSettings] to for user parsing configuration. * @author Alexander Capehart (OxygenCobalt) */ -class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song { +class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Song { override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) } @@ -62,10 +59,11 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song { update(rawSong.artistNames) update(rawSong.albumArtistNames) } - override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" } - override val rawSortName = rawSong.sortName - override val sortName = SortName((rawSortName ?: rawName), musicSettings) - override fun resolveName(context: Context) = rawName + override val name = + Name.Known.from( + requireNotNull(rawSong.name) { "Invalid raw: No title" }, + rawSong.sortName, + musicSettings) override val track = rawSong.track override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) } @@ -87,9 +85,9 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song { override val album: Album get() = unlikelyToBeNull(_album) - // Note: Only compare by UID so songs that differ only in MBID are treated differently. - override fun hashCode() = uid.hashCode() - override fun equals(other: Any?) = other is Song && uid == other.uid + override fun hashCode() = 31 * uid.hashCode() + rawSong.hashCode() + override fun equals(other: Any?) = + other is SongImpl && uid == other.uid && rawSong == other.rawSong private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings) private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings) @@ -238,10 +236,7 @@ class AlbumImpl( update(rawAlbum.name) update(rawAlbum.rawArtists.map { it.name }) } - override val rawName = rawAlbum.name - override val rawSortName = rawAlbum.sortName - override val sortName = SortName((rawSortName ?: rawName), musicSettings) - override fun resolveName(context: Context) = rawName + override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings) override val dates = Date.Range.from(songs.mapNotNull { it.date }) override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) @@ -249,11 +244,15 @@ class AlbumImpl( override val durationMs: Long override val dateAdded: Long - // Note: Append song contents to MusicParent equality so that Groups with - // the same UID but different contents are not equal. - override fun hashCode() = 31 * uid.hashCode() + songs.hashCode() + override fun hashCode(): Int { + var hashCode = uid.hashCode() + hashCode = 31 * hashCode + rawAlbum.hashCode() + hashCode = 31 * hashCode + songs.hashCode() + return hashCode + } + override fun equals(other: Any?) = - other is AlbumImpl && uid == other.uid && songs == other.songs + other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs private val _artists = mutableListOf() override val artists: List @@ -331,21 +330,29 @@ class ArtistImpl( // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) } ?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) } - override val rawName = rawArtist.name - override val rawSortName = rawArtist.sortName - override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) } - override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) - override val songs: List + override val name = + rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) } + ?: Name.Unknown(R.string.def_artist) + override val songs: List override val albums: List override val durationMs: Long? override val isCollaborator: Boolean - // Note: Append song contents to MusicParent equality so that Groups with - // the same UID but different contents are not equal. - override fun hashCode() = 31 * uid.hashCode() + songs.hashCode() + // Note: Append song contents to MusicParent equality so that artists with + // the same UID but different songs are not equal. + override fun hashCode(): Int { + var hashCode = uid.hashCode() + hashCode = 31 * hashCode + rawArtist.hashCode() + hashCode = 31 * hashCode + songs.hashCode() + return hashCode + } + override fun equals(other: Any?) = - other is ArtistImpl && uid == other.uid && songs == other.songs + other is ArtistImpl && + uid == other.uid && + rawArtist == other.rawArtist && + songs == other.songs override lateinit var genres: List @@ -416,20 +423,23 @@ class GenreImpl( override val songs: List ) : Genre { override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) } - override val rawName = rawGenre.name - override val rawSortName = rawName - override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) } - override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) + override val name = + rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) } + ?: Name.Unknown(R.string.def_genre) override val albums: List override val artists: List override val durationMs: Long - // Note: Append song contents to MusicParent equality so that Groups with - // the same UID but different contents are not equal. - override fun hashCode() = 31 * uid.hashCode() + songs.hashCode() + override fun hashCode(): Int { + var hashCode = uid.hashCode() + hashCode = 31 * hashCode + rawGenre.hashCode() + hashCode = 31 * hashCode + songs.hashCode() + return hashCode + } + override fun equals(other: Any?) = - other is GenreImpl && uid == other.uid && songs == other.songs + other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs init { val distinctAlbums = mutableSetOf() @@ -467,60 +477,8 @@ class GenreImpl( * * @return This instance upcasted to [Genre]. */ - fun finalize(): Music { + fun finalize(): Genre { check(songs.isNotEmpty()) { "Malformed genre: Empty" } return this } } - -/** - * Update a [MessageDigest] with a lowercase [String]. - * - * @param string The [String] to hash. If null, it will not be hashed. - */ -@VisibleForTesting -fun MessageDigest.update(string: String?) { - if (string != null) { - update(string.lowercase().toByteArray()) - } else { - update(0) - } -} - -/** - * Update a [MessageDigest] with the string representation of a [Date]. - * - * @param date The [Date] to hash. If null, nothing will be done. - */ -@VisibleForTesting -fun MessageDigest.update(date: Date?) { - if (date != null) { - update(date.toString().toByteArray()) - } else { - update(0) - } -} - -/** - * Update a [MessageDigest] with the lowercase versions of all of the input [String]s. - * - * @param strings The [String]s to hash. If a [String] is null, it will not be hashed. - */ -@VisibleForTesting -fun MessageDigest.update(strings: List) { - strings.forEach(::update) -} - -/** - * Update a [MessageDigest] with the little-endian bytes of a [Int]. - * - * @param n The [Int] to write. If null, nothing will be done. - */ -@VisibleForTesting -fun MessageDigest.update(n: Int?) { - if (n != null) { - update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte())) - } else { - update(0) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt similarity index 93% rename from app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt rename to app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index fa2042a60..23b02b2f1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -16,19 +16,20 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.model +package org.oxycblt.auxio.music.device import java.util.UUID import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.metadata.* -import org.oxycblt.auxio.music.storage.Directory +import org.oxycblt.auxio.music.fs.Directory +import org.oxycblt.auxio.music.info.Date +import org.oxycblt.auxio.music.info.ReleaseType /** * Raw information about a [SongImpl] obtained from the filesystem/Extractor instances. * * @author Alexander Capehart (OxygenCobalt) */ -class RawSong( +data class RawSong( /** * The ID of the [SongImpl]'s audio file, obtained from MediaStore. Note that this ID is highly * unstable and should only be used for accessing the audio file. @@ -50,15 +51,15 @@ class RawSong( var extensionMimeType: String? = null, /** @see Music.UID */ var musicBrainzId: String? = null, - /** @see Music.rawName */ + /** @see Music.name */ var name: String? = null, - /** @see Music.rawSortName */ + /** @see Music.name */ var sortName: String? = null, /** @see Song.track */ var track: Int? = null, - /** @see Disc.number */ + /** @see Song.disc */ var disc: Int? = null, - /** @See Disc.name */ + /** @See Song.disc */ var subtitle: String? = null, /** @see Song.date */ var date: Date? = null, @@ -93,7 +94,7 @@ class RawSong( * * @author Alexander Capehart (OxygenCobalt) */ -class RawAlbum( +data class RawAlbum( /** * The ID of the [AlbumImpl]'s grouping, obtained from MediaStore. Note that this ID is highly * unstable and should only be used for accessing the system-provided cover art. @@ -101,9 +102,9 @@ class RawAlbum( val mediaStoreId: Long, /** @see Music.uid */ val musicBrainzId: UUID?, - /** @see Music.rawName */ + /** @see Music.name */ val name: String, - /** @see Music.rawSortName */ + /** @see Music.name */ val sortName: String?, /** @see Album.releaseType */ val releaseType: ReleaseType?, @@ -140,12 +141,12 @@ class RawAlbum( * * @author Alexander Capehart (OxygenCobalt) */ -class RawArtist( +data class RawArtist( /** @see Music.UID */ val musicBrainzId: UUID? = null, - /** @see Music.rawName */ + /** @see Music.name */ val name: String? = null, - /** @see Music.rawSortName */ + /** @see Music.name */ val sortName: String? = null ) { // Artists are grouped as follows: @@ -182,17 +183,17 @@ class RawArtist( * * @author Alexander Capehart (OxygenCobalt) */ -class RawGenre( - /** @see Music.rawName */ +data class RawGenre( + /** @see Music.name */ val name: String? = null ) { - // Only group by the lowercase genre name. This allows Genre grouping to be - // case-insensitive, which may be helpful in some libraries with different ways of - // formatting genres. // Cache the hashCode for HashMap efficiency. private val hashCode = name?.lowercase().hashCode() + // Only group by the lowercase genre name. This allows Genre grouping to be + // case-insensitive, which may be helpful in some libraries with different ways of + // formatting genres. override fun hashCode() = hashCode override fun equals(other: Any?) = diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt index 7c0117968..5913c2b8c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.storage +package org.oxycblt.auxio.music.fs import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 83369efd4..defbb7c3f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2022 Auxio Project - * Filesystem.kt is part of Auxio. + * Fs.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.storage +package org.oxycblt.auxio.music.fs import android.content.Context import android.media.MediaFormat diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt similarity index 92% rename from app/src/main/java/org/oxycblt/auxio/music/storage/StorageModule.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index 11d0e5650..10c4192bc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * StorageModule.kt is part of Auxio. + * FsModule.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.storage +package org.oxycblt.auxio.music.fs import android.content.Context import dagger.Module @@ -28,7 +28,7 @@ import org.oxycblt.auxio.music.MusicSettings @Module @InstallIn(SingletonComponent::class) -class StorageModule { +class FsModule { @Provides fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) = MediaStoreExtractor.from(context, musicSettings) diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 1669ae516..0df80983b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.storage +package org.oxycblt.auxio.music.fs import android.content.Context import android.database.Cursor @@ -31,10 +31,10 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.cache.Cache -import org.oxycblt.auxio.music.metadata.Date +import org.oxycblt.auxio.music.device.RawSong +import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.parseId3v2PositionField import org.oxycblt.auxio.music.metadata.transformPositionField -import org.oxycblt.auxio.music.model.RawSong import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD @@ -178,8 +178,8 @@ private abstract class BaseMediaStoreExtractor( while (cursor.moveToNext()) { // Assume that a song can't inhabit multiple genre entries, as I - // doubt - // MediaStore is actually aware that songs can have multiple genres. + // doubt MediaStore is actually aware that songs can have multiple + // genres. genreNamesMap[cursor.getLong(songIdIndex)] = name } } @@ -210,7 +210,6 @@ private abstract class BaseMediaStoreExtractor( // Free the cursor and signal that no more incomplete songs will be produced by // this extractor. query.close() - incompleteSongs.close() } /** @@ -311,9 +310,8 @@ private abstract class BaseMediaStoreExtractor( rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from) // A non-existent album name should theoretically be the name of the folder it contained // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it - // the - // file is not actually in the root internal storage directory. We can't do anything to - // fix this, really. + // the file is not actually in the root internal storage directory. We can't do + // anything to fix this, really. rawSong.albumName = cursor.getString(albumIndex) // Android does not make a non-existent artist tag null, it instead fills it in // as , which makes absolutely no sense given how other columns default @@ -356,9 +354,6 @@ private abstract class BaseMediaStoreExtractor( // Note: The separation between version-specific backends may not be the cleanest. To preserve // speed, we only want to add redundancy on known issues, not with possible issues. -// Note: The separation between version-specific backends may not be the cleanest. To preserve -// speed, we only want to add redundancy on known issues, not with possible issues. - private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : BaseMediaStoreExtractor(context, musicSettings) { override val projection: Array diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt index fdd59f0d1..4ecea1336 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.storage +package org.oxycblt.auxio.music.fs import android.content.ActivityNotFoundException import android.net.Uri diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt index 0ed74674e..1a057bd94 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.storage +package org.oxycblt.auxio.music.fs import android.annotation.SuppressLint import android.content.ContentResolver diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/metadata/Date.kt rename to app/src/main/java/org/oxycblt/auxio/music/info/Date.kt index 388a81842..46e4130f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.metadata +package org.oxycblt.auxio.music.info import android.content.Context import java.text.ParseException @@ -185,9 +185,10 @@ class Date private constructor(private val tokens: List) : Comparable * A [Regex] that can parse a variable-precision ISO-8601 timestamp. Derived from * https://github.com/quodlibet/mutagen */ - private val ISO8601_REGEX = + private val ISO8601_REGEX by lazy { Regex( """^(\d{4})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""") + } /** * Create a [Date] from a year component. diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/music/metadata/Disc.kt rename to app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt index e75e517d1..759d52b49 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Disc.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.metadata +package org.oxycblt.auxio.music.info import org.oxycblt.auxio.list.Item @@ -27,7 +27,7 @@ import org.oxycblt.auxio.list.Item * @param name The name of the disc group, if any. Null if not present. */ class Disc(val number: Int, val name: String?) : Item, Comparable { - override fun hashCode() = number.hashCode() override fun equals(other: Any?) = other is Disc && number == other.number + override fun hashCode() = number.hashCode() override fun compareTo(other: Disc) = number.compareTo(other.number) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt new file mode 100644 index 000000000..3b7c3bfc7 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2023 Auxio Project + * Name.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.info + +import android.content.Context +import androidx.annotation.StringRes +import java.text.CollationKey +import java.text.Collator +import org.oxycblt.auxio.music.MusicSettings + +/** + * The name of a music item. + * + * This class automatically implements + * + * @author Alexander Capehart + */ +sealed interface Name : Comparable { + /** + * A logical first character that can be used to collate a sorted list of music. + * + * TODO: Move this to the home package + */ + val thumb: String + + /** + * Get a human-readable string representation of this instance. + * + * @param context [Context] required. + */ + fun resolve(context: Context): String + + /** A name that could be obtained for the music item. */ + sealed class Known : Name { + /** The raw name string obtained. Should be ignored in favor of [resolve]. */ + abstract val raw: String + /** The raw sort name string obtained. */ + abstract val sort: String? + + /** A tokenized version of the name that will be compared. */ + protected abstract val sortTokens: List + + /** An individual part of a name string that can be compared intelligently. */ + protected data class SortToken(val collationKey: CollationKey, val type: Type) : + Comparable { + override fun compareTo(other: SortToken): Int { + // Numeric tokens should always be lower than lexicographic tokens. + val modeComp = type.compareTo(other.type) + if (modeComp != 0) { + return modeComp + } + + // Numeric strings must be ordered by magnitude, thus immediately short-circuit + // the comparison if the lengths do not match. + if (type == Type.NUMERIC && + collationKey.sourceString.length != other.collationKey.sourceString.length) { + return collationKey.sourceString.length - other.collationKey.sourceString.length + } + + return collationKey.compareTo(other.collationKey) + } + + /** Denotes the type of comparison to be performed with this token. */ + enum class Type { + /** Compare as a digit string, like "65". */ + NUMERIC, + /** Compare as a standard alphanumeric string, like "65daysofstatic" */ + LEXICOGRAPHIC + } + } + + final override val thumb: String + get() = + // TODO: Remove these safety checks once you have real unit testing + sortTokens + .firstOrNull() + ?.run { collationKey.sourceString.firstOrNull() } + ?.let { if (it.isDigit()) "#" else it.uppercase() } + ?: "?" + + final override fun resolve(context: Context) = raw + + final override fun compareTo(other: Name) = + when (other) { + is Known -> { + // Progressively compare the sort tokens between each known name. + sortTokens.zip(other.sortTokens).fold(0) { acc, (token, otherToken) -> + acc.takeIf { it != 0 } ?: token.compareTo(otherToken) + } + } + // Unknown names always come before known names. + is Unknown -> 1 + } + + companion object { + /** + * Create a new instance of [Name.Known] + * + * @param raw The raw name obtained from the music item + * @param sort The raw sort name obtained from the music item + * @param musicSettings [MusicSettings] required for name configuration. + */ + fun from(raw: String, sort: String?, musicSettings: MusicSettings): Known = + if (musicSettings.intelligentSorting) { + IntelligentKnownName(raw, sort) + } else { + SimpleKnownName(raw, sort) + } + } + } + + /** + * A placeholder name that is used when a [Known] name could not be obtained for the item. + * + * @author Alexander Capehart + */ + data class Unknown(@StringRes val stringRes: Int) : Name { + override val thumb = "?" + override fun resolve(context: Context) = context.getString(stringRes) + override fun compareTo(other: Name) = + when (other) { + // Unknown names do not need any direct comparison right now. + is Unknown -> 0 + // Unknown names always come before known names. + is Known -> -1 + } + } +} + +private val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY } +private val PUNCT_REGEX by lazy { Regex("[\\p{Punct}+]") } + +/** + * Plain [Name.Known] implementation that is internationalization-safe. + * + * @author Alexander Capehart (OxygenCobalt) + */ +private data class SimpleKnownName(override val raw: String, override val sort: String?) : + Name.Known() { + override val sortTokens = listOf(parseToken(sort ?: raw)) + + private fun parseToken(name: String): SortToken { + // Remove excess punctuation from the string, as those usually aren't considered in sorting. + val stripped = name.replace(PUNCT_REGEX, "").ifEmpty { name } + val collationKey = COLLATOR.getCollationKey(stripped) + // Always use lexicographic mode since we aren't parsing any numeric components + return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC) + } +} + +/** + * [Name.Known] implementation that adds advanced sorting behavior at the cost of localization. + * + * @author Alexander Capehart (OxygenCobalt) + */ +private data class IntelligentKnownName(override val raw: String, override val sort: String?) : + Name.Known() { + override val sortTokens = parseTokens(sort ?: raw) + + private fun parseTokens(name: String): List { + val stripped = + name + // Remove excess punctuation from the string, as those u + .replace(PUNCT_REGEX, "") + .ifEmpty { name } + .run { + // Strip any english articles like "the" or "an" from the start, as music + // sorting should ignore such when possible. + when { + length > 4 && startsWith("the ", ignoreCase = true) -> substring(4) + length > 3 && startsWith("an ", ignoreCase = true) -> substring(3) + length > 2 && startsWith("a ", ignoreCase = true) -> substring(2) + else -> this + } + } + + // To properly compare numeric components in names, we have to split them up into + // individual lexicographic and numeric tokens and then individually compare them + // with special logic. + return TOKEN_REGEX.findAll(stripped).mapTo(mutableListOf()) { match -> + // Remove excess whitespace where possible + val token = match.value.trim().ifEmpty { match.value } + val collationKey: CollationKey + val type: SortToken.Type + // Separate each token into their numeric and lexicographic counterparts. + if (token.first().isDigit()) { + // The digit string comparison breaks with preceding zero digits, remove those + val digits = token.trimStart('0').ifEmpty { token } + // Other languages have other types of digit strings, still use collation keys + collationKey = COLLATOR.getCollationKey(digits) + type = SortToken.Type.NUMERIC + } else { + collationKey = COLLATOR.getCollationKey(token) + type = SortToken.Type.LEXICOGRAPHIC + } + SortToken(collationKey, type) + } + } + + companion object { + private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/ReleaseType.kt b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/music/metadata/ReleaseType.kt rename to app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt index a91966d56..20ac60034 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/ReleaseType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.metadata +package org.oxycblt.auxio.music.info import org.oxycblt.auxio.R @@ -28,15 +28,15 @@ import org.oxycblt.auxio.R * * @author Alexander Capehart (OxygenCobalt) */ -sealed class ReleaseType { +sealed interface ReleaseType { /** * A specification of what kind of performance this release is. If null, the release is * considered "Plain". */ - abstract val refinement: Refinement? + val refinement: Refinement? /** The string resource corresponding to the name of this release type to show in the UI. */ - abstract val stringRes: Int + val stringRes: Int /** * A plain album. @@ -44,7 +44,7 @@ sealed class ReleaseType { * @param refinement A specification of what kind of performance this release is. If null, the * release is considered "Plain". */ - data class Album(override val refinement: Refinement?) : ReleaseType() { + data class Album(override val refinement: Refinement?) : ReleaseType { override val stringRes: Int get() = when (refinement) { @@ -61,7 +61,7 @@ sealed class ReleaseType { * @param refinement A specification of what kind of performance this release is. If null, the * release is considered "Plain". */ - data class EP(override val refinement: Refinement?) : ReleaseType() { + data class EP(override val refinement: Refinement?) : ReleaseType { override val stringRes: Int get() = when (refinement) { @@ -78,7 +78,7 @@ sealed class ReleaseType { * @param refinement A specification of what kind of performance this release is. If null, the * release is considered "Plain". */ - data class Single(override val refinement: Refinement?) : ReleaseType() { + data class Single(override val refinement: Refinement?) : ReleaseType { override val stringRes: Int get() = when (refinement) { @@ -95,7 +95,7 @@ sealed class ReleaseType { * @param refinement A specification of what kind of performance this release is. If null, the * release is considered "Plain". */ - data class Compilation(override val refinement: Refinement?) : ReleaseType() { + data class Compilation(override val refinement: Refinement?) : ReleaseType { override val stringRes: Int get() = when (refinement) { @@ -110,7 +110,7 @@ sealed class ReleaseType { * A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually * visual) media. */ - object Soundtrack : ReleaseType() { + object Soundtrack : ReleaseType { override val refinement: Refinement? get() = null @@ -122,7 +122,7 @@ sealed class ReleaseType { * A (DJ) Mix. These are usually one large track consisting of the artist playing several * sub-tracks with smooth transitions between them. */ - object Mix : ReleaseType() { + object Mix : ReleaseType { override val refinement: Refinement? get() = null @@ -134,7 +134,7 @@ sealed class ReleaseType { * A Mix-tape. These are usually [EP]-sized releases of music made to promote an Artist or a * future release. */ - object Mixtape : ReleaseType() { + object Mixtape : ReleaseType { override val refinement: Refinement? get() = null diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioProperties.kt similarity index 83% rename from app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/AudioProperties.kt index b31b5f63f..acea28744 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioProperties.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * AudioInfo.kt is part of Auxio. + * AudioProperties.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,7 +24,7 @@ import android.media.MediaFormat import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.storage.MimeType +import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW @@ -37,32 +37,33 @@ import org.oxycblt.auxio.util.logW * @param resolvedMimeType The known mime type of the [Song] after it's file format was determined. * @author Alexander Capehart (OxygenCobalt) */ -data class AudioInfo( +data class AudioProperties( val bitrateKbps: Int?, val sampleRateHz: Int?, val resolvedMimeType: MimeType ) { - /** Implements the process of extracting [AudioInfo] from a given [Song]. */ - interface Provider { + /** Implements the process of extracting [AudioProperties] from a given [Song]. */ + interface Factory { /** - * Extract the [AudioInfo] of a given [Song]. + * Extract the [AudioProperties] of a given [Song]. * * @param song The [Song] to read. - * @return The [AudioInfo] of the [Song], if possible to obtain. + * @return The [AudioProperties] of the [Song], if possible to obtain. */ - suspend fun extract(song: Song): AudioInfo + suspend fun extract(song: Song): AudioProperties } } /** - * A framework-backed implementation of [AudioInfo.Provider]. + * A framework-backed implementation of [AudioProperties.Factory]. * * @param context [Context] required to read audio files. */ -class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) : - AudioInfo.Provider { +class AudioPropertiesFactoryImpl +@Inject +constructor(@ApplicationContext private val context: Context) : AudioProperties.Factory { - override suspend fun extract(song: Song): AudioInfo { + override suspend fun extract(song: Song): AudioProperties { // While we would use ExoPlayer to extract this information, it doesn't support // common data like bit rate in progressive data sources due to there being no // demand. Thus, we are stuck with the inferior OS-provided MediaExtractor. @@ -76,7 +77,7 @@ class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val // that we can show. logW("Unable to extract song attributes.") logW(e.stackTraceToString()) - return AudioInfo(null, null, song.mimeType) + return AudioProperties(null, null, song.mimeType) } // Get the first track from the extractor (This is basically always the only @@ -122,6 +123,6 @@ class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val extractor.release() - return AudioInfo(bitrate, sampleRate, resolvedMimeType) + return AudioProperties(bitrate, sampleRate, resolvedMimeType) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt index d6be65f67..96685a746 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt @@ -26,7 +26,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) interface MetadataModule { - @Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor - @Binds fun tagWorkerFactory(taskFactory: TagWorkerImpl.Factory): TagWorker.Factory - @Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider + @Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor + @Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory + @Binds fun audioPropertiesFactory(factory: AudioPropertiesFactoryImpl): AudioProperties.Factory } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt index 2acf872ba..5fa612667 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt @@ -99,6 +99,14 @@ class SeparatorsDialog : ViewBindingDialogFragment() { return separators } + private object Separators { + const val COMMA = ',' + const val SEMICOLON = ';' + const val SLASH = '/' + const val PLUS = '+' + const val AND = '&' + } + private companion object { const val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS" } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt index e9ed62dee..bbc2971a0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt @@ -18,11 +18,11 @@ package org.oxycblt.auxio.music.metadata -import com.google.android.exoplayer2.MetadataRetriever +import androidx.media3.exoplayer.MetadataRetriever import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield -import org.oxycblt.auxio.music.model.RawSong +import org.oxycblt.auxio.music.device.RawSong /** * The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the @@ -87,8 +87,6 @@ class TagExtractorImpl @Inject constructor(private val tagWorkerFactory: TagWork } } } while (ongoingTasks) - - completeSongs.close() } private companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt index 9dd1bbb36..62f19c61b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt @@ -209,7 +209,7 @@ private fun String.parseId3v1Genre(): String? { * A [Regex] that implements parsing for ID3v2's genre format. Derived from mutagen: * https://github.com/quodlibet/mutagen */ -private val ID3V2_GENRE_RE = Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?") +private val ID3V2_GENRE_RE by lazy { Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?") } /** * Parse an ID3v2 integer genre field, which has support for multiple genre values and combined @@ -228,7 +228,7 @@ private fun String.parseId3v2Genre(): List? { // Case 1: Genre IDs in the format (INT|RX|CR). If these exist, parse them as // ID3v1 tags. val genreIds = groups.getOrNull(1) - if (genreIds != null && genreIds.isNotEmpty()) { + if (!genreIds.isNullOrEmpty()) { val ids = genreIds.substring(1, genreIds.lastIndex).split(")(") for (id in ids) { id.parseId3v1Genre()?.let(genres::add) @@ -238,7 +238,7 @@ private fun String.parseId3v2Genre(): List? { // Case 2: Genre names as a normal string. The only case we have to look out for are // escaped strings formatted as ((genre). val genreName = groups.getOrNull(3) - if (genreName != null && genreName.isNotEmpty()) { + if (!genreName.isNullOrEmpty()) { if (genreName.startsWith("((")) { genres.add(genreName.substring(1)) } else { diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index 2cf328a92..115462a8a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -19,14 +19,15 @@ package org.oxycblt.auxio.music.metadata import androidx.core.text.isDigitsOnly -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.MetadataRetriever -import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.source.TrackGroupArray +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.MetadataRetriever +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.TrackGroupArray import java.util.concurrent.Future import javax.inject.Inject -import org.oxycblt.auxio.music.model.RawSong -import org.oxycblt.auxio.music.storage.toAudioUri +import org.oxycblt.auxio.music.device.RawSong +import org.oxycblt.auxio.music.fs.toAudioUri +import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -56,14 +57,26 @@ interface TagWorker { } } -class TagWorkerImpl -private constructor(private val rawSong: RawSong, private val future: Future) : - TagWorker { - /** - * Try to get a completed song from this [TagWorker], if it has finished processing. - * - * @return A [RawSong] instance if processing has completed, null otherwise. - */ +class TagWorkerFactoryImpl +@Inject +constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory { + override fun create(rawSong: RawSong): TagWorker = + // Note that we do not leverage future callbacks. This is because errors in the + // (highly fallible) extraction process will not bubble up to Indexer when a + // listener is used, instead crashing the app entirely. + TagWorkerImpl( + rawSong, + MetadataRetriever.retrieveMetadata( + mediaSourceFactory, + MediaItem.fromUri( + requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))) +} + +private class TagWorkerImpl( + private val rawSong: RawSong, + private val future: Future +) : TagWorker { + override fun poll(): RawSong? { if (!future.isDone) { // Not done yet, nothing to do. @@ -95,12 +108,6 @@ private constructor(private val rawSong: RawSong, private val future: Future>) { // Song textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() } @@ -123,6 +130,9 @@ private constructor(private val rawSong: RawSong, private val future: Future>): Date? { // Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY // is present. @@ -212,11 +214,6 @@ private constructor(private val rawSong: RawSong, private val future: Future>) { // Song comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() } @@ -270,28 +267,15 @@ private constructor(private val rawSong: RawSong, private val future: Future. + */ + +package org.oxycblt.auxio.music.picker + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.showToast + +/** + * A dialog that allows the user to pick a specific playlist to add song(s) to. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class AddToPlaylistDialog : + ViewBindingDialogFragment(), + ClickableListListener, + NewPlaylistFooterAdapter.Listener { + private val musicModel: MusicViewModel by activityViewModels() + private val pickerModel: PlaylistPickerViewModel by viewModels() + // Information about what playlist to name for is initially within the navigation arguments + // as UIDs, as that is the only safe way to parcel playlist information. + private val args: AddToPlaylistDialogArgs by navArgs() + private val choiceAdapter = PlaylistChoiceAdapter(this) + private val footerAdapter = NewPlaylistFooterAdapter(this) + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.lbl_playlists).setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogMusicChoicesBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMusicChoicesBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.choiceRecycler.apply { + itemAnimator = null + adapter = ConcatAdapter(choiceAdapter, footerAdapter) + } + + // --- VIEWMODEL SETUP --- + pickerModel.setSongsToAdd(args.songUids) + collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs) + collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices) + } + + override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { + super.onDestroyBinding(binding) + binding.choiceRecycler.adapter = null + } + + override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) { + musicModel.addToPlaylist(pickerModel.currentSongsToAdd.value ?: return, item.playlist) + requireContext().showToast(R.string.lng_playlist_added) + findNavController().navigateUp() + } + + override fun onNewPlaylist() { + musicModel.createPlaylist(songs = pickerModel.currentSongsToAdd.value ?: return) + } + + private fun updatePendingSongs(songs: List?) { + if (songs == null) { + // No songs to feasibly add to a playlist, leave. + findNavController().navigateUp() + } + } + + private fun updatePlaylistChoices(choices: List) { + choiceAdapter.update(choices, null) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt new file mode 100644 index 000000000..afc90c825 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023 Auxio Project + * DeletePlaylistDialog.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.picker + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * A [ViewBindingDialogFragment] that asks the user to confirm the deletion of a [Playlist]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class DeletePlaylistDialog : ViewBindingDialogFragment() { + private val pickerModel: PlaylistPickerViewModel by viewModels() + private val musicModel: MusicViewModel by activityViewModels() + // Information about what playlist to name for is initially within the navigation arguments + // as UIDs, as that is the only safe way to parcel playlist information. + private val args: DeletePlaylistDialogArgs by navArgs() + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder + .setTitle(R.string.lbl_confirm_delete_playlist) + .setPositiveButton(R.string.lbl_delete) { _, _ -> + // Now we can delete the playlist for-real this time. + musicModel.deletePlaylist( + unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true) + requireContext().showToast(R.string.lng_playlist_deleted) + } + .setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogDeletePlaylistBinding.inflate(inflater) + + override fun onBindingCreated( + binding: DialogDeletePlaylistBinding, + savedInstanceState: Bundle? + ) { + super.onBindingCreated(binding, savedInstanceState) + + // --- VIEWMODEL SETUP --- + pickerModel.setPlaylistToDelete(args.playlistUid) + collectImmediately(pickerModel.currentPlaylistToDelete, ::updatePlaylistToDelete) + } + + private fun updatePlaylistToDelete(playlist: Playlist?) { + if (playlist == null) { + // Playlist does not exist anymore, leave + findNavController().navigateUp() + return + } + + requireBinding().deletionInfo.text = + getString(R.string.fmt_deletion_info, playlist.name.resolve(requireContext())) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt new file mode 100644 index 000000000..4da8b50e7 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2023 Auxio Project + * NewPlaylistDialog.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.picker + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * A dialog allowing the name of a new playlist to be chosen before committing it to the database. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class NewPlaylistDialog : ViewBindingDialogFragment() { + private val musicModel: MusicViewModel by activityViewModels() + private val pickerModel: PlaylistPickerViewModel by viewModels() + // Information about what playlist to name for is initially within the navigation arguments + // as UIDs, as that is the only safe way to parcel playlist information. + private val args: NewPlaylistDialogArgs by navArgs() + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder + .setTitle(R.string.lbl_new_playlist) + .setPositiveButton(R.string.lbl_ok) { _, _ -> + val pendingPlaylist = unlikelyToBeNull(pickerModel.currentPendingPlaylist.value) + val name = + when (val chosenName = pickerModel.chosenName.value) { + is ChosenName.Valid -> chosenName.value + is ChosenName.Empty -> pendingPlaylist.preferredName + else -> throw IllegalStateException() + } + // TODO: Navigate to playlist if there are songs in it + musicModel.createPlaylist(name, pendingPlaylist.songs) + requireContext().showToast(R.string.lng_playlist_created) + findNavController().apply { + navigateUp() + // Do an additional navigation away from the playlist addition dialog, if + // needed. If that dialog isn't present, this should be a no-op. Hopefully. + navigateUp() + } + } + .setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogPlaylistNameBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + // --- UI SETUP --- + binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) } + + // --- VIEWMODEL SETUP --- + pickerModel.setPendingPlaylist(requireContext(), args.songUids) + collectImmediately(pickerModel.currentPendingPlaylist, ::updatePendingPlaylist) + collectImmediately(pickerModel.chosenName, ::updateChosenName) + } + + private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) { + if (pendingPlaylist == null) { + findNavController().navigateUp() + return + } + + requireBinding().playlistName.hint = pendingPlaylist.preferredName + } + + private fun updateChosenName(chosenName: ChosenName) { + (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = + chosenName is ChosenName.Valid || chosenName is ChosenName.Empty + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt new file mode 100644 index 000000000..fb7f1a965 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 Auxio Project + * NewPlaylistFooterAdapter.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.picker + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.databinding.ItemNewPlaylistChoiceBinding +import org.oxycblt.auxio.list.recycler.DialogRecyclerView +import org.oxycblt.auxio.util.inflater + +/** + * A purely-visual [RecyclerView.Adapter] that acts as a footer providing a "New Playlist" choice in + * [AddToPlaylistDialog]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class NewPlaylistFooterAdapter(private val listener: Listener) : + RecyclerView.Adapter() { + override fun getItemCount() = 1 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + NewPlaylistFooterViewHolder.from(parent) + + override fun onBindViewHolder(holder: NewPlaylistFooterViewHolder, position: Int) { + holder.bind(listener) + } + + /** A listener for [NewPlaylistFooterAdapter] interactions. */ + interface Listener { + /** + * Called when the footer has been pressed, requesting to create a new playlist to add to. + */ + fun onNewPlaylist() + } +} + +/** + * A [RecyclerView.ViewHolder] that displays a "New Playlist" choice in [NewPlaylistFooterAdapter]. + * Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class NewPlaylistFooterViewHolder +private constructor(private val binding: ItemNewPlaylistChoiceBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + /** + * Bind new data to this instance. + * + * @param listener A [NewPlaylistFooterAdapter.Listener] to bind interactions to. + */ + fun bind(listener: NewPlaylistFooterAdapter.Listener) { + binding.root.setOnClickListener { listener.onNewPlaylist() } + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: View) = + NewPlaylistFooterViewHolder( + ItemNewPlaylistChoiceBinding.inflate(parent.context.inflater)) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt new file mode 100644 index 000000000..02a5424e9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistChoiceAdapter.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.picker + +import android.view.View +import android.view.ViewGroup +import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback +import org.oxycblt.auxio.list.recycler.DialogRecyclerView +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.inflater + +/** + * A [FlexibleListAdapter] that displays a list of [PlaylistChoice] options to select from in + * [AddToPlaylistDialog]. + * + * @param listener [ClickableListListener] to bind interactions to. + */ +class PlaylistChoiceAdapter(val listener: ClickableListListener) : + FlexibleListAdapter( + PlaylistChoiceViewHolder.DIFF_CALLBACK) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + PlaylistChoiceViewHolder.from(parent) + + override fun onBindViewHolder(holder: PlaylistChoiceViewHolder, position: Int) { + holder.bind(getItem(position), listener) + } +} + +/** + * A [DialogRecyclerView.ViewHolder] that displays an individual playlist choice. Use [from] to + * create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class PlaylistChoiceViewHolder private constructor(private val binding: ItemPickerChoiceBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + fun bind(choice: PlaylistChoice, listener: ClickableListListener) { + listener.bind(choice, this) + binding.pickerImage.apply { + bind(choice.playlist) + isActivated = choice.alreadyAdded + } + binding.pickerName.text = choice.playlist.name.resolve(binding.context) + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: View) = + PlaylistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: PlaylistChoice, newItem: PlaylistChoice) = + oldItem.playlist.name == newItem.playlist.name && + oldItem.alreadyAdded == newItem.alreadyAdded + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt new file mode 100644 index 000000000..2181f4605 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistPickerViewModel.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.picker + +import android.content.Context +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.R +import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song + +/** + * A [ViewModel] managing the state of the playlist picker dialogs. + * + * @author Alexander Capehart + */ +@HiltViewModel +class PlaylistPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : + ViewModel(), MusicRepository.UpdateListener { + private val _currentPendingPlaylist = MutableStateFlow(null) + /** A new [Playlist] having it's name chosen by the user. Null if none yet. */ + val currentPendingPlaylist: StateFlow + get() = _currentPendingPlaylist + + private val _currentPlaylistToRename = MutableStateFlow(null) + /** An existing [Playlist] that is being renamed. Null if none yet. */ + val currentPlaylistToRename: StateFlow + get() = _currentPlaylistToRename + + private val _currentPlaylistToDelete = MutableStateFlow(null) + /** The current [Playlist] that needs it's deletion confirmed. Null if none yet. */ + val currentPlaylistToDelete: StateFlow + get() = _currentPlaylistToDelete + + private val _chosenName = MutableStateFlow(ChosenName.Empty) + /** The users chosen name for [currentPendingPlaylist] or [currentPlaylistToRename]. */ + val chosenName: StateFlow + get() = _chosenName + + private val _currentSongsToAdd = MutableStateFlow?>(null) + /** A batch of [Song]s to add to a playlist chosen by the user. Null if none yet. */ + val currentSongsToAdd: StateFlow?> + get() = _currentSongsToAdd + + private val _playlistAddChoices = MutableStateFlow>(listOf()) + /** The [Playlist]s that [currentSongsToAdd] could be added to. */ + val playlistAddChoices: StateFlow> + get() = _playlistAddChoices + + init { + musicRepository.addUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + var refreshChoicesWith: List? = null + val deviceLibrary = musicRepository.deviceLibrary + if (changes.deviceLibrary && deviceLibrary != null) { + _currentPendingPlaylist.value = + _currentPendingPlaylist.value?.let { pendingPlaylist -> + PendingPlaylist( + pendingPlaylist.preferredName, + pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) + } + _currentSongsToAdd.value = + _currentSongsToAdd.value?.let { pendingSongs -> + pendingSongs + .mapNotNull { deviceLibrary.findSong(it.uid) } + .ifEmpty { null } + .also { refreshChoicesWith = it } + } + } + + val chosenName = _chosenName.value + if (changes.userLibrary) { + when (chosenName) { + is ChosenName.Valid -> updateChosenName(chosenName.value) + is ChosenName.AlreadyExists -> updateChosenName(chosenName.prior) + else -> { + // Nothing to do. + } + } + refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value + } + + refreshChoicesWith?.let(::refreshPlaylistChoices) + } + + override fun onCleared() { + musicRepository.removeUpdateListener(this) + } + + /** + * Set a new [currentPendingPlaylist] from a new batch of pending [Song] [Music.UID]s. + * + * @param context [Context] required to generate a playlist name. + * @param songUids The [Music.UID]s of songs to be present in the playlist. + */ + fun setPendingPlaylist(context: Context, songUids: Array) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + val songs = songUids.mapNotNull(deviceLibrary::findSong) + + val userLibrary = musicRepository.userLibrary ?: return + var i = 1 + while (true) { + val possibleName = context.getString(R.string.fmt_def_playlist, i) + if (userLibrary.playlists.none { it.name.resolve(context) == possibleName }) { + _currentPendingPlaylist.value = PendingPlaylist(possibleName, songs) + return + } + ++i + } + } + + /** + * Set a new [currentPlaylistToRename] from a [Playlist] [Music.UID]. + * + * @param playlistUid The [Music.UID]s of the [Playlist] to rename. + */ + fun setPlaylistToRename(playlistUid: Music.UID) { + _currentPlaylistToRename.value = musicRepository.userLibrary?.findPlaylist(playlistUid) + } + + /** + * Set a new [currentPendingPlaylist] from a new [Playlist] [Music.UID]. + * + * @param playlistUid The [Music.UID] of the [Playlist] to delete. + */ + fun setPlaylistToDelete(playlistUid: Music.UID) { + _currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid) + } + + /** + * Update the current [chosenName] based on new user input. + * + * @param name The new user-inputted name, or null if not present. + */ + fun updateChosenName(name: String?) { + _chosenName.value = + when { + name.isNullOrEmpty() -> ChosenName.Empty + name.isBlank() -> ChosenName.Blank + else -> { + val trimmed = name.trim() + val userLibrary = musicRepository.userLibrary + if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) { + ChosenName.Valid(trimmed) + } else { + ChosenName.AlreadyExists(trimmed) + } + } + } + } + + /** + * Set a new [currentSongsToAdd] from a new batch of pending [Song] [Music.UID]s. + * + * @param songUids The [Music.UID]s of songs to add to a playlist. + */ + fun setSongsToAdd(songUids: Array) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + val songs = songUids.mapNotNull(deviceLibrary::findSong) + _currentSongsToAdd.value = songs + refreshPlaylistChoices(songs) + } + + private fun refreshPlaylistChoices(songs: List) { + val userLibrary = musicRepository.userLibrary ?: return + _playlistAddChoices.value = + Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map { + val songSet = it.songs.toSet() + PlaylistChoice(it, songs.all(songSet::contains)) + } + } +} + +/** + * Represents a playlist that will be created as soon as a name is chosen. + * + * @param preferredName The name to be used by default if no other name is chosen. + * @param songs The [Song]s to be contained in the [PendingPlaylist] + * @author Alexander Capehart (OxygenCobalt) + */ +data class PendingPlaylist(val preferredName: String, val songs: List) + +/** + * Represents the (processed) user input from the playlist naming dialogs. + * + * @author Alexander Capehart (OxygenCobalt) + */ +sealed interface ChosenName { + /** The current name is valid. */ + data class Valid(val value: String) : ChosenName + /** The current name already exists. */ + data class AlreadyExists(val prior: String) : ChosenName + /** The current name is empty. */ + object Empty : ChosenName + /** The current name only consists of whitespace. */ + object Blank : ChosenName +} + +/** + * An individual [Playlist] choice to add [Song]s to. + * + * @param playlist The [Playlist] represented. + * @param alreadyAdded Whether the songs currently pending addition have already been added to the + * [Playlist]. + * @author Alexander Capehart (OxygenCobalt) + */ +data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean) : Item diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt new file mode 100644 index 000000000..fcc8b2538 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 Auxio Project + * RenamePlaylistDialog.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.picker + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * A dialog allowing the name of a new playlist to be chosen before committing it to the database. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class RenamePlaylistDialog : ViewBindingDialogFragment() { + private val musicModel: MusicViewModel by activityViewModels() + private val pickerModel: PlaylistPickerViewModel by viewModels() + // Information about what playlist to name for is initially within the navigation arguments + // as UIDs, as that is the only safe way to parcel playlist information. + private val args: RenamePlaylistDialogArgs by navArgs() + private var initializedField = false + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder + .setTitle(R.string.lbl_rename_playlist) + .setPositiveButton(R.string.lbl_ok) { _, _ -> + val playlist = unlikelyToBeNull(pickerModel.currentPlaylistToRename.value) + val chosenName = pickerModel.chosenName.value as ChosenName.Valid + musicModel.renamePlaylist(playlist, chosenName.value) + requireContext().showToast(R.string.lng_playlist_renamed) + findNavController().navigateUp() + } + .setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogPlaylistNameBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + // --- UI SETUP --- + binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) } + + // --- VIEWMODEL SETUP --- + pickerModel.setPlaylistToRename(args.playlistUid) + collectImmediately(pickerModel.currentPlaylistToRename, ::updatePlaylistToRename) + collectImmediately(pickerModel.chosenName, ::updateChosenName) + } + + private fun updatePlaylistToRename(playlist: Playlist?) { + if (playlist == null) { + // Nothing to rename anymore. + findNavController().navigateUp() + return + } + + if (!initializedField) { + requireBinding().playlistName.setText(playlist.name.resolve(requireContext())) + initializedField = true + } + } + + private fun updateChosenName(chosenName: ChosenName) { + (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = + chosenName is ChosenName.Valid + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt deleted file mode 100644 index 0a67af75d..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ /dev/null @@ -1,454 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * Indexer.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.system - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import androidx.core.content.ContextCompat -import java.util.LinkedList -import javax.inject.Inject -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.cache.CacheRepository -import org.oxycblt.auxio.music.metadata.TagExtractor -import org.oxycblt.auxio.music.model.Library -import org.oxycblt.auxio.music.model.RawSong -import org.oxycblt.auxio.music.storage.MediaStoreExtractor -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logE -import org.oxycblt.auxio.util.logW - -/** - * Core music loading state class. - * - * This class provides low-level access into the exact state of the music loading process. **This - * class should not be used in most cases.** It is highly volatile and provides far more information - * than is usually needed. Use [MusicRepository] instead if you do not need to work with the exact - * music loading state. - * - * @author Alexander Capehart (OxygenCobalt) - */ -interface Indexer { - /** Whether music loading is occurring or not. */ - val isIndexing: Boolean - /** - * Whether this instance has not completed a loading process and is not currently loading music. - * This often occurs early in an app's lifecycle, and consumers should try to avoid showing any - * state when this flag is true. - */ - val isIndeterminate: Boolean - - /** - * Register a [Controller] for this instance. This instance will handle any commands to start - * the music loading process. There can be only one [Controller] at a time. Will invoke all - * [Listener] methods to initialize the instance with the current state. - * - * @param controller The [Controller] to register. Will do nothing if already registered. - */ - fun registerController(controller: Controller) - - /** - * Unregister the [Controller] from this instance, prevent it from recieving any further - * commands. - * - * @param controller The [Controller] to unregister. Must be the current [Controller]. Does - * nothing if invoked by another [Controller] implementation. - */ - fun unregisterController(controller: Controller) - - /** - * Register the [Listener] for this instance. This can be used to receive rapid-fire updates to - * the current music loading state. There can be only one [Listener] at a time. Will invoke all - * [Listener] methods to initialize the instance with the current state. - * - * @param listener The [Listener] to add. - */ - fun registerListener(listener: Listener) - - /** - * Unregister a [Listener] from this instance, preventing it from recieving any further updates. - * - * @param listener The [Listener] to unregister. Must be the current [Listener]. Does nothing if - * invoked by another [Listener] implementation. - * @see Listener - */ - fun unregisterListener(listener: Listener) - - /** - * Start the indexing process. This should be done from in the background from [Controller]'s - * context after a command has been received to start the process. - * - * @param context [Context] required to load music. - * @param withCache Whether to use the cache or not when loading. If false, the cache will still - * be written, but no cache entries will be loaded into the new library. - * @param scope The [CoroutineScope] to run the indexing job in. - * @return The [Job] stacking the indexing status. - */ - fun index(context: Context, withCache: Boolean, scope: CoroutineScope): Job - - /** - * Request that the music library should be reloaded. This should be used by components that do - * not manage the indexing process in order to signal that the [Indexer.Controller] should call - * [index] eventually. - * - * @param withCache Whether to use the cache when loading music. Does nothing if there is no - * [Indexer.Controller]. - */ - fun requestReindex(withCache: Boolean) - - /** - * Reset the current loading state to signal that the instance is not loading. This should be - * called by [Controller] after it's indexing co-routine was cancelled. - */ - fun reset() - - /** Represents the current state of [Indexer]. */ - sealed class State { - /** - * Music loading is ongoing. - * - * @param indexing The current music loading progress.. - * @see Indexer.Indexing - */ - data class Indexing(val indexing: Indexer.Indexing) : State() - - /** - * Music loading has completed. - * - * @param result The outcome of the music loading process. - */ - data class Complete(val result: Result) : State() - } - - /** - * Represents the current progress of the music loader. Usually encapsulated in a [State]. - * - * @see State.Indexing - */ - sealed class Indexing { - /** - * Music loading is occurring, but no definite estimate can be put on the current progress. - */ - object Indeterminate : Indexing() - - /** - * Music loading has a definite progress. - * - * @param current The current amount of songs that have been loaded. - * @param total The projected total amount of songs that will be loaded. - */ - class Songs(val current: Int, val total: Int) : Indexing() - } - - /** Thrown when the required permissions to load the music library have not been granted yet. */ - class NoPermissionException : Exception() { - override val message: String - get() = "Not granted permissions to load music library" - } - - /** Thrown when no music was found on the device. */ - class NoMusicException : Exception() { - override val message: String - get() = "Unable to find any music" - } - - /** - * A listener for rapid-fire changes in the music loading state. - * - * This is only useful for code that absolutely must show the current loading process. - * Otherwise, [MusicRepository.Listener] is highly recommended due to it's updates only - * consisting of the [Library]. - */ - interface Listener { - /** - * Called when the current state of the Indexer changed. - * - * Notes: - * - Null means that no loading is going on, but no load has completed either. - * - [State.Complete] may represent a previous load, if the current loading process was - * canceled for one reason or another. - */ - fun onIndexerStateChanged(state: State?) - } - - /** - * Context that runs the music loading process. Implementations should be capable of running the - * background for long periods of time without android killing the process. - */ - interface Controller : Listener { - /** - * Called when a new music loading process was requested. Implementations should forward - * this to [index]. - * - * @param withCache Whether to use the cache or not when loading. If false, the cache should - * still be written, but no cache entries will be loaded into the new library. - * @see index - */ - fun onStartIndexing(withCache: Boolean) - } - - companion object { - /** - * A version-compatible identifier for the read external storage permission required by the - * system to load audio. - */ - val PERMISSION_READ_AUDIO = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // READ_EXTERNAL_STORAGE was superseded by READ_MEDIA_AUDIO in Android 13 - Manifest.permission.READ_MEDIA_AUDIO - } else { - Manifest.permission.READ_EXTERNAL_STORAGE - } - } -} - -class IndexerImpl -@Inject -constructor( - private val musicSettings: MusicSettings, - private val cacheRepository: CacheRepository, - private val mediaStoreExtractor: MediaStoreExtractor, - private val tagExtractor: TagExtractor -) : Indexer { - @Volatile private var lastResponse: Result? = null - @Volatile private var indexingState: Indexer.Indexing? = null - @Volatile private var controller: Indexer.Controller? = null - @Volatile private var listener: Indexer.Listener? = null - - override val isIndexing: Boolean - get() = indexingState != null - - override val isIndeterminate: Boolean - get() = lastResponse == null && indexingState == null - - @Synchronized - override fun registerController(controller: Indexer.Controller) { - if (BuildConfig.DEBUG && this.controller != null) { - logW("Controller is already registered") - return - } - - // Initialize the controller with the current state. - val currentState = - indexingState?.let { Indexer.State.Indexing(it) } - ?: lastResponse?.let { Indexer.State.Complete(it) } - controller.onIndexerStateChanged(currentState) - this.controller = controller - } - - @Synchronized - override fun unregisterController(controller: Indexer.Controller) { - if (BuildConfig.DEBUG && this.controller !== controller) { - logW("Given controller did not match current controller") - return - } - - this.controller = null - } - - @Synchronized - override fun registerListener(listener: Indexer.Listener) { - if (BuildConfig.DEBUG && this.listener != null) { - logW("Listener is already registered") - return - } - - // Initialize the listener with the current state. - val currentState = - indexingState?.let { Indexer.State.Indexing(it) } - ?: lastResponse?.let { Indexer.State.Complete(it) } - listener.onIndexerStateChanged(currentState) - this.listener = listener - } - - @Synchronized - override fun unregisterListener(listener: Indexer.Listener) { - if (BuildConfig.DEBUG && this.listener !== listener) { - logW("Given controller did not match current controller") - return - } - - this.listener = null - } - - override fun index(context: Context, withCache: Boolean, scope: CoroutineScope) = - scope.launch { - val result = - try { - val start = System.currentTimeMillis() - val library = indexImpl(context, withCache, this) - logD( - "Music indexing completed successfully in " + - "${System.currentTimeMillis() - start}ms") - Result.success(library) - } catch (e: CancellationException) { - // Got cancelled, propagate upwards to top-level co-routine. - logD("Loading routine was cancelled") - throw e - } catch (e: Exception) { - // Music loading process failed due to something we have not handled. - logE("Music indexing failed") - logE(e.stackTraceToString()) - Result.failure(e) - } - emitCompletion(result) - } - - @Synchronized - override fun requestReindex(withCache: Boolean) { - logD("Requesting reindex") - controller?.onStartIndexing(withCache) - } - - @Synchronized - override fun reset() { - logD("Cancelling last job") - emitIndexing(null) - } - - private suspend fun indexImpl( - context: Context, - withCache: Boolean, - scope: CoroutineScope - ): Library { - if (ContextCompat.checkSelfPermission(context, Indexer.PERMISSION_READ_AUDIO) == - PackageManager.PERMISSION_DENIED) { - logE("Permission check failed") - // No permissions, signal that we can't do anything. - throw Indexer.NoPermissionException() - } - - // Start initializing the extractors. Use an indeterminate state, as there is no ETA on - // how long a media database query will take. - emitIndexing(Indexer.Indexing.Indeterminate) - - // Do the initial query of the cache and media databases in parallel. - logD("Starting queries") - val mediaStoreQueryJob = scope.tryAsync { mediaStoreExtractor.query() } - val cache = - if (withCache) { - cacheRepository.readCache() - } else { - null - } - // TODO: Stupid, actually bubble results properly - val query = mediaStoreQueryJob.await().getOrThrow() - - // Now start processing the queried song information in parallel. Songs that can't be - // received from the cache are consisted incomplete and pushed to a separate channel - // that will eventually be processed into completed raw songs. - logD("Starting song discovery") - val completeSongs = Channel(Channel.UNLIMITED) - val incompleteSongs = Channel(Channel.UNLIMITED) - val mediaStoreJob = - scope.tryAsync { - mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) - } - val metadataJob = scope.tryAsync { tagExtractor.consume(incompleteSongs, completeSongs) } - - // Await completed raw songs as they are processed. - val rawSongs = LinkedList() - for (rawSong in completeSongs) { - rawSongs.add(rawSong) - emitIndexing(Indexer.Indexing.Songs(rawSongs.size, query.projectedTotal)) - } - // These should be no-ops - mediaStoreJob.await().getOrThrow() - metadataJob.await().getOrThrow() - - if (rawSongs.isEmpty()) { - logE("Music library was empty") - throw Indexer.NoMusicException() - } - - // Successfully loaded the library, now save the cache and create the library in - // parallel. - logD("Discovered ${rawSongs.size} songs, starting finalization") - emitIndexing(Indexer.Indexing.Indeterminate) - val libraryJob = scope.tryAsync(Dispatchers.Main) { Library.from(rawSongs, musicSettings) } - if (cache == null || cache.invalidated) { - cacheRepository.writeCache(rawSongs) - } - return libraryJob.await().getOrThrow() - } - - private inline fun CoroutineScope.tryAsync( - context: CoroutineContext = EmptyCoroutineContext, - crossinline block: suspend () -> R - ) = - async(context) { - try { - Result.success(block()) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Emit a new [Indexer.State.Indexing] state. This can be used to signal the current state of - * the music loading process to external code. Assumes that the callee has already checked if - * they have not been canceled and thus have the ability to emit a new state. - * - * @param indexing The new [Indexer.Indexing] state to emit, or null if no loading process is - * occurring. - */ - @Synchronized - private fun emitIndexing(indexing: Indexer.Indexing?) { - indexingState = indexing - // If we have canceled the loading process, we want to revert to a previous completion - // whenever possible to prevent state inconsistency. - val state = - indexingState?.let { Indexer.State.Indexing(it) } - ?: lastResponse?.let { Indexer.State.Complete(it) } - controller?.onIndexerStateChanged(state) - listener?.onIndexerStateChanged(state) - } - - /** - * Emit a new [Indexer.State.Complete] state. This can be used to signal the completion of the - * music loading process to external code. Will check if the callee has not been canceled and - * thus has the ability to emit a new state - * - * @param result The new [Result] to emit, representing the outcome of the music loading - * process. - */ - private suspend fun emitCompletion(result: Result) { - yield() - // Swap to the Main thread so that downstream callbacks don't crash from being on - // a background thread. Does not occur in emitIndexing due to efficiency reasons. - withContext(Dispatchers.Main) { - synchronized(this) { - // Do not check for redundancy here, as we actually need to notify a switch - // from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete. - lastResponse = result - indexingState = null - // Signal that the music loading process has been completed. - val state = Indexer.State.Complete(result) - controller?.onIndexerStateChanged(state) - listener?.onIndexerStateChanged(state) - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt index 5f72fac18..301fa1b46 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt @@ -24,6 +24,7 @@ import androidx.core.app.NotificationCompat import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.IndexingProgress import org.oxycblt.auxio.service.ForegroundServiceNotification import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent @@ -56,22 +57,22 @@ class IndexingNotification(private val context: Context) : /** * Update this notification with the new music loading state. * - * @param indexing The new music loading state to display in the notification. + * @param progress The new music loading state to display in the notification. * @return true if the notification updated, false otherwise */ - fun updateIndexingState(indexing: Indexer.Indexing): Boolean { - when (indexing) { - is Indexer.Indexing.Indeterminate -> { + fun updateIndexingState(progress: IndexingProgress): Boolean { + when (progress) { + is IndexingProgress.Indeterminate -> { // Indeterminate state, use a vaguer description and in-determinate progress. // These events are not very frequent, and thus we don't need to safeguard // against rate limiting. - logD("Updating state to $indexing") + logD("Updating state to $progress") lastUpdateTime = -1 setContentText(context.getString(R.string.lng_indexing)) setProgress(0, 0, true) return true } - is Indexer.Indexing.Songs -> { + is IndexingProgress.Songs -> { // Determinate state, show an active progress meter. Since these updates arrive // highly rapidly, only update every 1.5 seconds to prevent notification rate // limiting. @@ -80,10 +81,10 @@ class IndexingNotification(private val context: Context) : return false } lastUpdateTime = SystemClock.elapsedRealtime() - logD("Updating state to $indexing") + logD("Updating state to $progress") setContentText( - context.getString(R.string.fmt_indexing, indexing.current, indexing.total)) - setProgress(indexing.total, indexing.current, false) + context.getString(R.string.fmt_indexing, progress.current, progress.total)) + setProgress(progress.total, progress.current, false) return true } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index def283ba5..eee390b11 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -28,14 +28,13 @@ import android.os.PowerManager import android.provider.MediaStore import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint +import java.lang.Runnable +import java.util.* import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.* import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.storage.contentResolverSafe +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.service.ForegroundManager import org.oxycblt.auxio.util.getSystemServiceCompat @@ -46,7 +45,7 @@ import org.oxycblt.auxio.util.logD * * Loading music is a time-consuming process that would likely be killed by the system before it * could complete if ran anywhere else. So, this [Service] manages the music loading process as an - * instance of [Indexer.Controller]. + * instance of [MusicRepository.IndexingWorker]. * * This [Service] also handles automatic rescanning, as that is a similarly long-running background * operation that would be unsuitable elsewhere in the app. @@ -56,12 +55,17 @@ import org.oxycblt.auxio.util.logD * TODO: Unify with PlaybackService as part of the service independence project */ @AndroidEntryPoint -class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { +class IndexerService : + Service(), + MusicRepository.IndexingWorker, + MusicRepository.IndexingListener, + MusicRepository.UpdateListener, + MusicSettings.Listener { @Inject lateinit var imageLoader: ImageLoader @Inject lateinit var musicRepository: MusicRepository - @Inject lateinit var indexer: Indexer @Inject lateinit var musicSettings: MusicSettings @Inject lateinit var playbackManager: PlaybackStateManager + private val serviceJob = Job() private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) private var currentIndexJob: Job? = null @@ -85,13 +89,9 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // condition to cause us to load music before we were fully initialize. indexerContentObserver = SystemContentObserver() musicSettings.registerListener(this) - indexer.registerController(this) - // An indeterminate indexer and a missing library implies we are extremely early - // in app initialization so start loading music. - if (musicRepository.library == null && indexer.isIndeterminate) { - logD("No library present and no previous response, indexing music now") - onStartIndexing(true) - } + musicRepository.addUpdateListener(this) + musicRepository.addIndexingListener(this) + musicRepository.registerWorker(this) logD("Service created.") } @@ -109,83 +109,66 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // events will not occur. indexerContentObserver.release() musicSettings.unregisterListener(this) - indexer.unregisterController(this) + musicRepository.removeUpdateListener(this) + musicRepository.removeIndexingListener(this) + musicRepository.unregisterWorker(this) // Then cancel any remaining music loading jobs. serviceJob.cancel() - indexer.reset() } // --- CONTROLLER CALLBACKS --- - override fun onStartIndexing(withCache: Boolean) { - if (indexer.isIndexing) { - // Cancel the previous music loading job. - currentIndexJob?.cancel() - indexer.reset() - } + override fun requestIndex(withCache: Boolean) { + // Cancel the previous music loading job. + currentIndexJob?.cancel() // Start a new music loading job on a co-routine. - currentIndexJob = indexer.index(this@IndexerService, withCache, indexScope) + currentIndexJob = + indexScope.launch { musicRepository.index(this@IndexerService, withCache) } } - override fun onIndexerStateChanged(state: Indexer.State?) { - when (state) { - is Indexer.State.Indexing -> updateActiveSession(state.indexing) - is Indexer.State.Complete -> { - val newLibrary = state.result.getOrNull() - if (newLibrary != null && newLibrary != musicRepository.library) { - logD("Applying new library") - // We only care if the newly-loaded library is going to replace a previously - // loaded library. - if (musicRepository.library != null) { - // Wipe possibly-invalidated outdated covers - imageLoader.memoryCache?.clear() - // Clear invalid models from PlaybackStateManager. This is not connected - // to a listener as it is bad practice for a shared object to attach to - // the listener system of another. - playbackManager.toSavedState()?.let { savedState -> - playbackManager.applySavedState( - PlaybackStateManager.SavedState( - parent = savedState.parent?.let(newLibrary::sanitize), - queueState = - savedState.queueState.remap { song -> - newLibrary.sanitize(requireNotNull(song)) - }, - positionMs = savedState.positionMs, - repeatMode = savedState.repeatMode), - true) - } - } - // Forward the new library to MusicStore to continue the update process. - musicRepository.library = newLibrary - } - // On errors, while we would want to show a notification that displays the - // error, that requires the Android 13 notification permission, which is not - // handled right now. - updateIdleSession() - } - null -> { - // Null is the indeterminate state that occurs on app startup or after - // the cancellation of a load, so in that case we want to stop foreground - // since (technically) nothing is loading. - updateIdleSession() - } + override val context = this + + override val scope = indexScope + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + // Wipe possibly-invalidated outdated covers + imageLoader.memoryCache?.clear() + // Clear invalid models from PlaybackStateManager. This is not connected + // to a listener as it is bad practice for a shared object to attach to + // the listener system of another. + playbackManager.toSavedState()?.let { savedState -> + playbackManager.applySavedState( + PlaybackStateManager.SavedState( + parent = + savedState.parent?.let { musicRepository.find(it.uid) as? MusicParent }, + queueState = + savedState.queueState.remap { song -> + deviceLibrary.findSong(requireNotNull(song).uid) + }, + positionMs = savedState.positionMs, + repeatMode = savedState.repeatMode), + true) + } + } + + override fun onIndexingStateChanged() { + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + updateActiveSession(state.progress) + } else { + updateIdleSession() } } // --- INTERNAL --- - /** - * Update the current state to "Active", in which the service signals that music loading is - * on-going. - * - * @param state The current music loading state. - */ - private fun updateActiveSession(state: Indexer.Indexing) { + private fun updateActiveSession(progress: IndexingProgress) { // When loading, we want to enter the foreground state so that android does // not shut off the loading process. Note that while we will always post the // notification when initially starting, we will not update the notification // unless it indicates that it has changed. - val changed = indexingNotification.updateIndexingState(state) + val changed = indexingNotification.updateIndexingState(progress) if (!foregroundManager.tryStartForeground(indexingNotification) && changed) { logD("Notification changed, re-posting notification") indexingNotification.post() @@ -194,10 +177,6 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { wakeLock.acquireSafe() } - /** - * Update the current state to "Idle", in which it either does nothing or signals that it's - * currently monitoring the music library for changes. - */ private fun updateIdleSession() { if (musicSettings.shouldBeObserving) { // There are a few reasons why we stay in the foreground with automatic rescanning: @@ -244,7 +223,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { override fun onIndexingSettingChanged() { // Music loading configuration changed, need to reload music. - onStartIndexing(true) + requestIndex(true) } override fun onObservingChanged() { @@ -252,7 +231,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // notification if we were actively loading when the automatic rescanning // setting changed. In such a case, the state will still be updated when // the music loading process ends. - if (!indexer.isIndexing) { + if (currentIndexJob == null) { updateIdleSession() } } @@ -290,7 +269,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // Check here if we should even start a reindex. This is much less bug-prone than // registering and de-registering this component as this setting changes. if (musicSettings.shouldBeObserving) { - onStartIndexing(true) + requestIndex(true) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt new file mode 100644 index 000000000..00127a846 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistImpl.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.user + +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.info.Name + +class PlaylistImpl +private constructor( + override val uid: Music.UID, + override val name: Name.Known, + override val songs: List +) : Playlist { + override val durationMs = songs.sumOf { it.durationMs } + override val albums = + songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key } + + /** + * Clone the data in this instance to a new [PlaylistImpl] with the given [name]. + * + * @param name The new name to use. + * @param musicSettings [MusicSettings] required for name configuration. + */ + fun edit(name: String, musicSettings: MusicSettings) = + PlaylistImpl(uid, Name.Known.from(name, null, musicSettings), songs) + + /** + * Clone the data in this instance to a new [PlaylistImpl] with the given [Song]s. + * + * @param songs The new [Song]s to use. + */ + fun edit(songs: List) = PlaylistImpl(uid, name, songs) + + /** + * Clone the data in this instance to a new [PlaylistImpl] with the given [edits]. + * + * @param edits The edits to make to the [Song]s of the playlist. + */ + inline fun edit(edits: MutableList.() -> Unit) = edit(songs.toMutableList().apply(edits)) + + override fun equals(other: Any?) = + other is PlaylistImpl && uid == other.uid && name == other.name && songs == other.songs + + override fun hashCode(): Int { + var hashCode = uid.hashCode() + hashCode = 31 * hashCode + name.hashCode() + hashCode = 31 * hashCode + songs.hashCode() + return hashCode + } + + companion object { + /** + * Create a new instance with a novel UID. + * + * @param name The name of the playlist. + * @param songs The songs to initially populate the playlist with. + * @param musicSettings [MusicSettings] required for name configuration. + */ + fun from(name: String, songs: List, musicSettings: MusicSettings) = + PlaylistImpl( + Music.UID.auxio(MusicMode.PLAYLISTS), + Name.Known.from(name, null, musicSettings), + songs) + + /** + * Populate a new instance from a read [RawPlaylist]. + * + * @param rawPlaylist The [RawPlaylist] to read from. + * @param deviceLibrary The [DeviceLibrary] to initialize from. + * @param musicSettings [MusicSettings] required for name configuration. + */ + fun fromRaw( + rawPlaylist: RawPlaylist, + deviceLibrary: DeviceLibrary, + musicSettings: MusicSettings + ) = + PlaylistImpl( + rawPlaylist.playlistInfo.playlistUid, + Name.Known.from(rawPlaylist.playlistInfo.name, null, musicSettings), + rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) }) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt new file mode 100644 index 000000000..96d3b5e77 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 Auxio Project + * RawPlaylist.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.user + +import androidx.room.* +import org.oxycblt.auxio.music.Music + +/** + * Raw playlist information persisted to [UserMusicDatabase]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +data class RawPlaylist( + @Embedded val playlistInfo: PlaylistInfo, + @Relation( + parentColumn = "playlistUid", + entityColumn = "songUid", + associateBy = Junction(PlaylistSongCrossRef::class)) + val songs: List +) + +/** + * UID and name information corresponding to a [RawPlaylist] entry. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@Entity data class PlaylistInfo(@PrimaryKey val playlistUid: Music.UID, val name: String) + +/** + * Song information corresponding to a [RawPlaylist] entry. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@Entity data class PlaylistSong(@PrimaryKey val songUid: Music.UID) + +/** + * Links individual songs to a playlist entry. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@Entity +data class PlaylistSongCrossRef( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val playlistUid: Music.UID, + @ColumnInfo(index = true) val songUid: Music.UID +) diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt new file mode 100644 index 000000000..fc64f5918 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2023 Auxio Project + * UserLibrary.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.user + +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.device.DeviceLibrary + +/** + * Organized library information controlled by the user. + * + * Unlike [DeviceLibrary], [UserLibrary]s can be mutated without needing to clone the instance. It + * is also not backed by library information, rather an app database with in-memory caching. It is + * generally not expected to create this yourself, and instead rely on MusicRepository. + * + * @author Alexander Capehart + */ +interface UserLibrary { + /** The current user-defined playlists. */ + val playlists: List + + /** + * Find a [Playlist] instance corresponding to the given [Music.UID]. + * + * @param uid The [Music.UID] to search for. + * @return The corresponding [Song], or null if one was not found. + */ + fun findPlaylist(uid: Music.UID): Playlist? + + /** + * Finds a playlist by it's [name]. Since all [Playlist] names must be unique, this will always + * return at most 1 value. + * + * @param name The name [String] to search for. + */ + fun findPlaylist(name: String): Playlist? + + /** Constructs a [UserLibrary] implementation in an asynchronous manner. */ + interface Factory { + /** + * Create a new [UserLibrary]. + * + * @param deviceLibraryChannel Asynchronously populated [DeviceLibrary] that can be obtained + * later. This allows database information to be read before the actual instance is + * constructed. + * @return A new [MutableUserLibrary] with the required implementation. + */ + suspend fun read(deviceLibraryChannel: Channel): MutableUserLibrary + } +} + +/** + * A mutable instance of [UserLibrary]. Not meant for use outside of the music module. Use + * [MusicRepository] instead. + * + * @author Alexander Capehart (OxygenCobalt) + */ +interface MutableUserLibrary : UserLibrary { + /** + * Make a new [Playlist]. + * + * @param name The name of the [Playlist]. + * @param songs The songs to place in the [Playlist]. + */ + 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 [Song]s to a [Playlist]. + * + * @param playlist The [Playlist] to add to. Must currently exist. + */ + suspend fun addToPlaylist(playlist: Playlist, songs: List) + + /** + * 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) +} + +class UserLibraryFactoryImpl +@Inject +constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : + UserLibrary.Factory { + override suspend fun read(deviceLibraryChannel: Channel): MutableUserLibrary { + // While were waiting for the library, read our playlists out. + val rawPlaylists = playlistDao.readRawPlaylists() + val deviceLibrary = deviceLibraryChannel.receive() + // Convert the database playlist information to actual usable playlists. + val playlistMap = mutableMapOf() + for (rawPlaylist in rawPlaylists) { + val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, musicSettings) + playlistMap[playlistImpl.uid] = playlistImpl + } + return UserLibraryImpl(playlistDao, playlistMap, musicSettings) + } +} + +private class UserLibraryImpl( + private val playlistDao: PlaylistDao, + private val playlistMap: MutableMap, + private val musicSettings: MusicSettings +) : MutableUserLibrary { + override val playlists: List + get() = playlistMap.values.toList() + + override fun findPlaylist(uid: Music.UID) = playlistMap[uid] + + override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } + + override suspend fun createPlaylist(name: String, songs: List) { + val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) + synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } + val rawPlaylist = + RawPlaylist( + PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw), + playlistImpl.songs.map { PlaylistSong(it.uid) }) + playlistDao.insertPlaylist(rawPlaylist) + } + + override suspend fun renamePlaylist(playlist: Playlist, name: String) { + val playlistImpl = + requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } + synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) } + playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name)) + } + + override suspend fun deletePlaylist(playlist: Playlist) { + synchronized(this) { + requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" } + } + playlistDao.deletePlaylist(playlist.uid) + } + + override suspend fun addToPlaylist(playlist: Playlist, songs: List) { + val playlistImpl = + requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } + synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } } + playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) + } + + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { + val playlistImpl = + requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" } + synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(songs) } + playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt new file mode 100644 index 000000000..10e55c5bd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 Auxio Project + * UserModule.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.user + +import android.content.Context +import androidx.room.Room +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface UserModule { + @Binds fun userLibraryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory +} + +@Module +@InstallIn(SingletonComponent::class) +class UserRoomModule { + @Provides fun playlistDao(database: UserMusicDatabase) = database.playlistDao() + + @Provides + fun userMusicDatabase(@ApplicationContext context: Context) = + Room.databaseBuilder( + context.applicationContext, UserMusicDatabase::class.java, "user_music.db") + .fallbackToDestructiveMigration() + .fallbackToDestructiveMigrationFrom(0) + .fallbackToDestructiveMigrationOnDowngrade() + .build() +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt new file mode 100644 index 000000000..087e46b35 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2023 Auxio Project + * UserMusicDatabase.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.user + +import androidx.room.* +import org.oxycblt.auxio.music.Music + +/** + * Allows persistence of all user-created music information. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@Database( + entities = [PlaylistInfo::class, PlaylistSong::class, PlaylistSongCrossRef::class], + version = 30, + exportSchema = false) +@TypeConverters(Music.UID.TypeConverters::class) +abstract class UserMusicDatabase : RoomDatabase() { + abstract fun playlistDao(): PlaylistDao +} + +// TODO: Handle playlist defragmentation? I really don't want dead songs to accumulate in this +// database. + +/** + * The DAO for persisted playlist information. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@Dao +interface PlaylistDao { + /** + * Read out all playlists stored in the database. + * + * @return A list of [RawPlaylist] representing each playlist stored. + */ + @Transaction + @Query("SELECT * FROM PlaylistInfo") + suspend fun readRawPlaylists(): List + + /** + * Create a new playlist. + * + * @param rawPlaylist The [RawPlaylist] to create. + */ + @Transaction + suspend fun insertPlaylist(rawPlaylist: RawPlaylist) { + insertInfo(rawPlaylist.playlistInfo) + insertSongs(rawPlaylist.songs) + insertRefs( + rawPlaylist.songs.map { + PlaylistSongCrossRef( + playlistUid = rawPlaylist.playlistInfo.playlistUid, songUid = it.songUid) + }) + } + + /** + * Replace the currently-stored [PlaylistInfo] for a playlist entry. + * + * @param playlistInfo The new [PlaylistInfo] to store. + */ + @Transaction + suspend fun replacePlaylistInfo(playlistInfo: PlaylistInfo) { + deleteInfo(playlistInfo.playlistUid) + insertInfo(playlistInfo) + } + + /** + * Delete a playlist entry's [PlaylistInfo] and [PlaylistSong]. + * + * @param playlistUid The [Music.UID] of the playlist to delete. + */ + @Transaction + suspend fun deletePlaylist(playlistUid: Music.UID) { + deleteInfo(playlistUid) + deleteRefs(playlistUid) + } + + /** + * Insert new song entries into a playlist. + * + * @param playlistUid The [Music.UID] of the playlist to insert into. + * @param songs The [PlaylistSong] representing each song to put into the playlist. + */ + @Transaction + suspend fun insertPlaylistSongs(playlistUid: Music.UID, songs: List) { + insertSongs(songs) + insertRefs( + songs.map { PlaylistSongCrossRef(playlistUid = playlistUid, songUid = it.songUid) }) + } + + /** + * Replace the currently stored songs of the given playlist entry. + * + * @param playlistUid The [Music.UID] of the playlist to update. + * @param songs The [PlaylistSong] representing the new list of songs to be placed in the + * playlist. + */ + @Transaction + suspend fun replacePlaylistSongs(playlistUid: Music.UID, songs: List) { + deleteRefs(playlistUid) + insertSongs(songs) + insertRefs( + songs.map { PlaylistSongCrossRef(playlistUid = playlistUid, songUid = it.songUid) }) + } + + /** Internal, do not use. */ + @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertInfo(info: PlaylistInfo) + + /** Internal, do not use. */ + @Query("DELETE FROM PlaylistInfo where playlistUid = :playlistUid") + suspend fun deleteInfo(playlistUid: Music.UID) + + /** Internal, do not use. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSongs(songs: List) + + /** Internal, do not use. */ + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertRefs(refs: List) + + /** Internal, do not use. */ + @Query("DELETE FROM PlaylistSongCrossRef where playlistUid = :playlistUid") + suspend fun deleteRefs(playlistUid: Music.UID) +} diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt b/app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt new file mode 100644 index 000000000..59c778320 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Auxio Project + * MainNavigationAction.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.navigation + +import androidx.navigation.NavDirections + +/** + * Represents the possible actions within the main navigation graph. This can be used with + * [NavigationViewModel] to initiate navigation in the main navigation graph from anywhere in the + * app, including outside the main navigation graph. + * + * @author Alexander Capehart (OxygenCobalt) + */ +sealed interface MainNavigationAction { + /** Expand the playback panel. */ + object OpenPlaybackPanel : MainNavigationAction + + /** Collapse the playback bottom sheet. */ + object ClosePlaybackPanel : MainNavigationAction + + /** + * Navigate to the given [NavDirections]. + * + * @param directions The [NavDirections] to navigate to. Assumed to be part of the main + * navigation graph. + */ + data class Directions(val directions: NavDirections) : MainNavigationAction +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt similarity index 81% rename from app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt index 116f57013..6e2f43f83 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt @@ -16,10 +16,9 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.ui +package org.oxycblt.auxio.navigation import androidx.lifecycle.ViewModel -import androidx.navigation.NavDirections import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music @@ -28,7 +27,13 @@ import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.logD -/** A [ViewModel] that handles complicated navigation functionality. */ +/** + * A [ViewModel] that handles complicated navigation functionality. + * + * @author Alexander Capehart (OxygenCobalt) + * + * TODO: This whole system is very jankily designed, perhaps it's time for a refactor? + */ class NavigationViewModel : ViewModel() { private val _mainNavigationAction = MutableEvent() /** @@ -80,7 +85,7 @@ class NavigationViewModel : ViewModel() { logD("Already navigating, not doing explore action") return } - logD("Navigating to ${music.rawName}") + logD("Navigating to ${music.name}") _exploreNavigationItem.put(music) } @@ -113,31 +118,8 @@ class NavigationViewModel : ViewModel() { if (artists.size == 1) { exploreNavigateTo(artists[0]) } else { - logD("Navigating to a choice of ${artists.map { it.rawName }}") + logD("Navigating to a choice of ${artists.map { it.name }}") _exploreArtistNavigationItem.put(item) } } } - -/** - * Represents the possible actions within the main navigation graph. This can be used with - * [NavigationViewModel] to initiate navigation in the main navigation graph from anywhere in the - * app, including outside the main navigation graph. - * - * @author Alexander Capehart (OxygenCobalt) - */ -sealed class MainNavigationAction { - /** Expand the playback panel. */ - object Expand : MainNavigationAction() - - /** Collapse the playback bottom sheet. */ - object Collapse : MainNavigationAction() - - /** - * Navigate to the given [NavDirections]. - * - * @param directions The [NavDirections] to navigate to. Assumed to be part of the main - * navigation graph. - */ - data class Directions(val directions: NavDirections) : MainNavigationAction() -} diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationChoiceAdapter.kt new file mode 100644 index 000000000..a397ec23b --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationChoiceAdapter.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023 Auxio Project + * ArtistNavigationChoiceAdapter.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.navigation.picker + +import android.view.View +import android.view.ViewGroup +import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback +import org.oxycblt.auxio.list.recycler.DialogRecyclerView +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.inflater + +/** + * A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with + * [NavigateToArtistDialog]. + * + * @param listener A [ClickableListListener] to bind interactions to. + */ +class ArtistNavigationChoiceAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter( + ArtistNavigationChoiceViewHolder.DIFF_CALLBACK) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ArtistNavigationChoiceViewHolder.from(parent) + + override fun onBindViewHolder(holder: ArtistNavigationChoiceViewHolder, position: Int) { + holder.bind(getItem(position), listener) + } +} + +/** + * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Artist] item, for + * use [ArtistNavigationChoiceAdapter]. Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class ArtistNavigationChoiceViewHolder +private constructor(private val binding: ItemPickerChoiceBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + /** + * Bind new data to this instance. + * + * @param artist The new [Artist] to bind. + * @param listener A [ClickableListListener] to bind interactions to. + */ + fun bind(artist: Artist, listener: ClickableListListener) { + listener.bind(artist, this) + binding.pickerImage.bind(artist) + binding.pickerName.text = artist.name.resolve(binding.context) + } + + companion object { + + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: View) = + ArtistNavigationChoiceViewHolder( + ItemPickerChoiceBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = + oldItem.name == newItem.name + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt new file mode 100644 index 000000000..a8614af77 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2022 Auxio Project + * NavigateToArtistDialog.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.navigation.picker + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.navigation.NavigationViewModel +import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.collectImmediately + +/** + * A picker [ViewBindingDialogFragment] intended for when [Artist] navigation is ambiguous. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class NavigateToArtistDialog : + ViewBindingDialogFragment(), ClickableListListener { + private val navigationModel: NavigationViewModel by activityViewModels() + private val pickerModel: NavigationPickerViewModel by viewModels() + // Information about what artists to show choices for is initially within the navigation + // arguments as UIDs, as that is the only safe way to parcel an artist. + private val args: NavigateToArtistDialogArgs by navArgs() + private val choiceAdapter = ArtistNavigationChoiceAdapter(this) + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.lbl_artists).setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogMusicChoicesBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMusicChoicesBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.choiceRecycler.apply { + itemAnimator = null + adapter = choiceAdapter + } + + pickerModel.setArtistChoiceUid(args.itemUid) + collectImmediately(pickerModel.artistChoices) { + if (it != null) { + choiceAdapter.update(it.choices, UpdateInstructions.Replace(0)) + } else { + findNavController().navigateUp() + } + } + } + + override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { + super.onDestroyBinding(binding) + choiceAdapter + } + + override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { + // User made a choice, navigate to the artist. + navigationModel.exploreNavigateTo(item) + findNavController().navigateUp() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt new file mode 100644 index 000000000..b09b74ae9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2023 Auxio Project + * NavigationPickerViewModel.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.navigation.picker + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.music.* + +/** + * A [ViewModel] that stores the current information required for navigation picker dialogs + * + * @author Alexander Capehart (OxygenCobalt) + */ +@HiltViewModel +class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : + ViewModel(), MusicRepository.UpdateListener { + private val _artistChoices = MutableStateFlow(null) + /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ + val artistChoices: StateFlow + get() = _artistChoices + + init { + musicRepository.addUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.deviceLibrary) return + val deviceLibrary = musicRepository.deviceLibrary ?: return + // Need to sanitize different items depending on the current set of choices. + _artistChoices.value = + when (val choices = _artistChoices.value) { + is SongArtistNavigationChoices -> + deviceLibrary.findSong(choices.song.uid)?.let { + SongArtistNavigationChoices(it) + } + is AlbumArtistNavigationChoices -> + deviceLibrary.findAlbum(choices.album.uid)?.let { + AlbumArtistNavigationChoices(it) + } + else -> null + } + } + + override fun onCleared() { + super.onCleared() + musicRepository.removeUpdateListener(this) + } + + /** + * Set the [Music.UID] of the item to show artist choices for. + * + * @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album]. + */ + fun setArtistChoiceUid(itemUid: Music.UID) { + // Support Songs and Albums, which have parent artists. + _artistChoices.value = + when (val music = musicRepository.find(itemUid)) { + is Song -> SongArtistNavigationChoices(music) + is Album -> AlbumArtistNavigationChoices(music) + else -> null + } + } +} + +/** + * The current list of choices to show in the artist navigation picker dialog. + * + * @author Alexander Capehart (OxygenCobalt) + */ +sealed interface ArtistNavigationChoices { + /** The current [Artist] choices. */ + val choices: List +} + +/** Backing implementation of [ArtistNavigationChoices] that is based on a [Song]. */ +private data class SongArtistNavigationChoices(val song: Song) : ArtistNavigationChoices { + override val choices = song.artists +} + +/** + * Backing implementation of [ArtistNavigationChoices] that is based on an + * [AlbumArtistNavigationChoices]. + */ +private data class AlbumArtistNavigationChoices(val album: Album) : ArtistNavigationChoices { + override val choices = album.artists +} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/picker/ArtistNavigationPickerDialog.kt deleted file mode 100644 index a43fc61a0..000000000 --- a/app/src/main/java/org/oxycblt/auxio/picker/ArtistNavigationPickerDialog.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * ArtistNavigationPickerDialog.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.picker - -import android.os.Bundle -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import org.oxycblt.auxio.databinding.DialogMusicPickerBinding -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.ui.NavigationViewModel - -/** - * An [ArtistPickerDialog] intended for when [Artist] navigation is ambiguous. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@AndroidEntryPoint -class ArtistNavigationPickerDialog : ArtistPickerDialog() { - private val navModel: NavigationViewModel by activityViewModels() - // Information about what Song to show choices for is initially within the navigation arguments - // as UIDs, as that is the only safe way to parcel a Song. - private val args: ArtistNavigationPickerDialogArgs by navArgs() - - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { - pickerModel.setItemUid(args.itemUid) - super.onBindingCreated(binding, savedInstanceState) - } - - override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { - super.onClick(item, viewHolder) - // User made a choice, navigate to it. - navModel.exploreNavigateTo(item) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/ArtistPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/picker/ArtistPickerDialog.kt deleted file mode 100644 index bf8e48265..000000000 --- a/app/src/main/java/org/oxycblt/auxio/picker/ArtistPickerDialog.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * ArtistPickerDialog.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.picker - -import android.os.Bundle -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.DialogMusicPickerBinding -import org.oxycblt.auxio.list.ClickableListListener -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.ui.ViewBindingDialogFragment -import org.oxycblt.auxio.util.collectImmediately - -/** - * The base class for dialogs that implements common behavior across all [Artist] pickers. These are - * shown whenever what to do with an item's [Artist] is ambiguous, as there are multiple [Artist]'s - * to choose from. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@AndroidEntryPoint -abstract class ArtistPickerDialog : - ViewBindingDialogFragment(), ClickableListListener { - protected val pickerModel: PickerViewModel by viewModels() - // Okay to leak this since the Listener will not be called until after initialization. - private val artistAdapter = ArtistChoiceAdapter(@Suppress("LeakingThis") this) - - override fun onCreateBinding(inflater: LayoutInflater) = - DialogMusicPickerBinding.inflate(inflater) - - override fun onConfigDialog(builder: AlertDialog.Builder) { - builder.setTitle(R.string.lbl_artists).setNegativeButton(R.string.lbl_cancel, null) - } - - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { - binding.pickerRecycler.adapter = artistAdapter - - collectImmediately(pickerModel.artistChoices) { artists -> - if (artists.isNotEmpty()) { - // Make sure the artist choices align with any changes in the music library. - artistAdapter.submitList(artists) - } else { - // Not showing any choices, navigate up. - findNavController().navigateUp() - } - } - } - - override fun onDestroyBinding(binding: DialogMusicPickerBinding) { - binding.pickerRecycler.adapter = null - } - - override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { - findNavController().navigateUp() - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/picker/ArtistPlaybackPickerDialog.kt deleted file mode 100644 index 36a73aa81..000000000 --- a/app/src/main/java/org/oxycblt/auxio/picker/ArtistPlaybackPickerDialog.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * ArtistPlaybackPickerDialog.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.picker - -import android.os.Bundle -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import org.oxycblt.auxio.databinding.DialogMusicPickerBinding -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.requireIs -import org.oxycblt.auxio.util.unlikelyToBeNull - -/** - * An [ArtistPickerDialog] intended for when [Artist] playback is ambiguous. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@AndroidEntryPoint -class ArtistPlaybackPickerDialog : ArtistPickerDialog() { - private val playbackModel: PlaybackViewModel by activityViewModels() - // Information about what Song to show choices for is initially within the navigation arguments - // as UIDs, as that is the only safe way to parcel a Song. - private val args: ArtistPlaybackPickerDialogArgs by navArgs() - - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { - pickerModel.setItemUid(args.itemUid) - super.onBindingCreated(binding, savedInstanceState) - } - - override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { - super.onClick(item, viewHolder) - // User made a choice, play the given song from that artist. - val song = requireIs(unlikelyToBeNull(pickerModel.currentItem.value)) - playbackModel.playFromArtist(song, item) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt deleted file mode 100644 index c10892dd5..000000000 --- a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * PickerViewModel.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.picker - -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.model.Library -import org.oxycblt.auxio.util.unlikelyToBeNull - -/** - * a [ViewModel] that manages the current music picker state. Make it so that the dialogs just - * contain the music themselves and then exit if the library changes. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@HiltViewModel -class PickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : - ViewModel(), MusicRepository.Listener { - - private val _currentItem = MutableStateFlow(null) - /** The current item whose artists should be shown in the picker. Null if there is no item. */ - val currentItem: StateFlow - get() = _currentItem - - private val _artistChoices = MutableStateFlow>(listOf()) - /** The current [Artist] choices. Empty if no item is shown in the picker. */ - val artistChoices: StateFlow> - get() = _artistChoices - - private val _genreChoices = MutableStateFlow>(listOf()) - /** The current [Genre] choices. Empty if no item is shown in the picker. */ - val genreChoices: StateFlow> - get() = _genreChoices - - override fun onCleared() { - musicRepository.removeListener(this) - } - - override fun onLibraryChanged(library: Library?) { - if (library != null) { - refreshChoices() - } - } - - /** - * Set a new [currentItem] from it's [Music.UID]. - * - * @param uid The [Music.UID] of the [Song] to update to. - */ - fun setItemUid(uid: Music.UID) { - val library = unlikelyToBeNull(musicRepository.library) - _currentItem.value = library.find(uid) - refreshChoices() - } - - private fun refreshChoices() { - when (val item = _currentItem.value) { - is Song -> { - _artistChoices.value = item.artists - _genreChoices.value = item.genres - } - is Album -> _artistChoices.value = item.artists - else -> {} - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt index bda248be7..f49d6df44 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -26,9 +26,9 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.navigation.MainNavigationAction +import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.ui.MainNavigationAction -import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.getAttrColorCompat @@ -56,7 +56,7 @@ class PlaybackBarFragment : ViewBindingFragment() { // --- UI SETUP --- binding.root.apply { - setOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Expand) } + setOnClickListener { navModel.mainNavigateTo(MainNavigationAction.OpenPlaybackPanel) } setOnLongClickListener { playbackModel.song.value?.let(navModel::exploreNavigateTo) true @@ -124,7 +124,7 @@ class PlaybackBarFragment : ViewBindingFragment() { val context = requireContext() val binding = requireBinding() binding.playbackCover.bind(song) - binding.playbackSong.text = song.resolveName(context) + binding.playbackSong.text = song.name.resolve(context) binding.playbackInfo.text = song.artists.resolveNames(context) binding.playbackProgressBar.max = song.durationMs.msToDs().toInt() } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt index 55233002d..36fb9a0ee 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt @@ -18,25 +18,9 @@ package org.oxycblt.auxio.playback -import android.content.Context -import com.google.android.exoplayer2.extractor.ExtractorsFactory -import com.google.android.exoplayer2.extractor.flac.FlacExtractor -import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor -import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor -import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor -import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor -import com.google.android.exoplayer2.extractor.ogg.OggExtractor -import com.google.android.exoplayer2.extractor.ts.AdtsExtractor -import com.google.android.exoplayer2.extractor.wav.WavExtractor -import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.source.ProgressiveMediaSource -import com.google.android.exoplayer2.upstream.ContentDataSource -import com.google.android.exoplayer2.upstream.DataSource 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.playback.state.PlaybackStateManager @@ -50,35 +34,3 @@ interface PlaybackModule { fun stateManager(playbackManager: PlaybackStateManagerImpl): PlaybackStateManager @Binds fun settings(playbackSettings: PlaybackSettingsImpl): PlaybackSettings } - -@Module -@InstallIn(SingletonComponent::class) -class ExoPlayerModule { - @Provides - fun mediaSourceFactory( - dataSourceFactory: DataSource.Factory, - extractorsFactory: ExtractorsFactory - ): MediaSource.Factory = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) - - @Provides - fun dataSourceFactory(@ApplicationContext context: Context) = - // We only ever open conte tURIs, so only provide those data sources. - DataSource.Factory { ContentDataSource(context) } - - @Provides - fun extractorsFactory() = ExtractorsFactory { - // Define our own extractors so we can exclude non-audio parsers. - // Ordering is derived from the DefaultExtractorsFactory's optimized ordering: - // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. - arrayOf( - FlacExtractor(), - WavExtractor(), - FragmentedMp4Extractor(), - Mp4Extractor(), - OggExtractor(), - MatroskaExtractor(), - // Enable constant bitrate seeking so that certain MP3s/AACs are seekable - AdtsExtractor(AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING), - Mp3Extractor(Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING)) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index 67137eeb9..f7bad82e9 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -34,12 +34,13 @@ import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.navigation.MainNavigationAction +import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.ui.StyledSeekBar -import org.oxycblt.auxio.ui.MainNavigationAction -import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.showToast @@ -50,6 +51,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * available controls. * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Improve flickering situation on play button */ @AndroidEntryPoint class PlaybackPanelFragment : @@ -57,6 +60,7 @@ class PlaybackPanelFragment : Toolbar.OnMenuItemClickListener, StyledSeekBar.Listener { private val playbackModel: PlaybackViewModel by activityViewModels() + private val musicModel: MusicViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels() private var equalizerLauncher: ActivityResultLauncher? = null @@ -84,7 +88,9 @@ class PlaybackPanelFragment : } binding.playbackToolbar.apply { - setNavigationOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Collapse) } + setNavigationOnClickListener { + navModel.mainNavigateTo(MainNavigationAction.ClosePlaybackPanel) + } setOnMenuItemClickListener(this@PlaybackPanelFragment) } @@ -162,6 +168,10 @@ class PlaybackPanelFragment : navigateToCurrentAlbum() true } + R.id.action_playlist_add -> { + playbackModel.song.value?.let(musicModel::addToPlaylist) + true + } R.id.action_song_detail -> { playbackModel.song.value?.let { song -> navModel.mainNavigateTo( @@ -186,9 +196,9 @@ class PlaybackPanelFragment : val binding = requireBinding() val context = requireContext() binding.playbackCover.bind(song) - binding.playbackSong.text = song.resolveName(context) + binding.playbackSong.text = song.name.resolve(context) binding.playbackArtist.text = song.artists.resolveNames(context) - binding.playbackAlbum.text = song.album.resolveName(context) + binding.playbackAlbum.text = song.album.name.resolve(context) binding.playbackSeekBar.durationDs = song.durationMs.msToDs() } @@ -196,7 +206,7 @@ class PlaybackPanelFragment : val binding = requireBinding() val context = requireContext() binding.playbackToolbar.subtitle = - parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs) + parent?.run { name.resolve(context) } ?: context.getString(R.string.lbl_all_songs) } private fun updatePosition(positionDs: Long) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index 9301bb341..68f2cac16 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -168,6 +168,7 @@ constructor( * - If [MusicMode.ALBUMS], the [Song] is played from it's [Album]. * - If [MusicMode.ARTISTS], the [Song] is played from one of it's [Artist]s. * - If [MusicMode.GENRES], the [Song] is played from one of it's [Genre]s. + * [MusicMode.PLAYLISTS] is disallowed here. * * @param song The [Song] to play. * @param playbackMode The [MusicMode] to play from. @@ -178,6 +179,7 @@ constructor( MusicMode.ALBUMS -> playImpl(song, song.album) MusicMode.ARTISTS -> playFromArtist(song) MusicMode.GENRES -> playFromGenre(song) + MusicMode.PLAYLISTS -> error("Playing from a playlist is not supported.") } } @@ -199,7 +201,7 @@ constructor( } /** - * PLay a [Song] from one of it's [Genre]s. + * Play a [Song] from one of it's [Genre]s. * * @param song The [Song] to play. * @param genre The [Genre] to play from. Must be linked to the [Song]. If null, the user will @@ -215,6 +217,16 @@ constructor( } } + /** + * PLay a [Song] from one of it's [Playlist]s. + * + * @param song The [Song] to play. + * @param playlist The [Playlist] to play from. Must be linked to the [Song]. + */ + fun playFromPlaylist(song: Song, playlist: Playlist) { + playImpl(song, playlist) + } + /** * Play an [Album]. * @@ -237,12 +249,18 @@ constructor( fun play(genre: Genre) = playImpl(null, genre, false) /** - * Play a [Music] selection. + * Play a [Playlist]. * - * @param selection The selection to play. + * @param playlist The [Playlist] to play. */ - fun play(selection: List) = - playbackManager.play(null, null, selectionToSongs(selection), false) + fun play(playlist: Playlist) = playImpl(null, playlist, false) + + /** + * Play a list of [Song]s. + * + * @param songs The [Song]s to play. + */ + fun play(songs: List) = playbackManager.play(null, null, songs, false) /** * Shuffle an [Album]. @@ -259,19 +277,25 @@ constructor( fun shuffle(artist: Artist) = playImpl(null, artist, true) /** - * Shuffle an [Genre]. + * Shuffle a [Genre]. * * @param genre The [Genre] to shuffle. */ fun shuffle(genre: Genre) = playImpl(null, genre, true) /** - * Shuffle a [Music] selection. + * Shuffle a [Playlist]. * - * @param selection The selection to shuffle. + * @param playlist The [Playlist] to shuffle. */ - fun shuffle(selection: List) = - playbackManager.play(null, null, selectionToSongs(selection), true) + fun shuffle(playlist: Playlist) = playImpl(null, playlist, true) + + /** + * Shuffle a list of [Song]s. + * + * @param songs The [Song]s to shuffle. + */ + fun shuffle(songs: List) = playbackManager.play(null, null, songs, true) private fun playImpl( song: Song?, @@ -281,15 +305,15 @@ constructor( check(song == null || parent == null || parent.songs.contains(song)) { "Song to play not in parent" } - val library = musicRepository.library ?: return - val sort = + val deviceLibrary = musicRepository.deviceLibrary ?: return + val queue = when (parent) { - is Genre -> musicSettings.genreSongSort - is Artist -> musicSettings.artistSongSort - is Album -> musicSettings.albumSongSort - null -> musicSettings.songSort + is Genre -> musicSettings.genreSongSort.songs(parent.songs) + is Artist -> musicSettings.artistSongSort.songs(parent.songs) + is Album -> musicSettings.albumSongSort.songs(parent.songs) + is Playlist -> parent.songs + null -> musicSettings.songSort.songs(deviceLibrary.songs) } - val queue = sort.songs(parent?.songs ?: library.songs) playbackManager.play(song, parent, queue, shuffled) } @@ -363,12 +387,21 @@ constructor( } /** - * Add a selection to the top of the queue. + * Add a [Playlist] to the top of the queue. * - * @param selection The [Music] selection to add. + * @param playlist The [Playlist] to add. */ - fun playNext(selection: List) { - playbackManager.playNext(selectionToSongs(selection)) + fun playNext(playlist: Playlist) { + playbackManager.playNext(playlist.songs) + } + + /** + * Add [Song]s to the top of the queue. + * + * @param songs The [Song]s to add. + */ + fun playNext(songs: List) { + playbackManager.playNext(songs) } /** @@ -408,12 +441,21 @@ constructor( } /** - * Add a selection to the end of the queue. + * Add a [Playlist] to the end of the queue. * - * @param selection The [Music] selection to add. + * @param playlist The [Playlist] to add. */ - fun addToQueue(selection: List) { - playbackManager.addToQueue(selectionToSongs(selection)) + fun addToQueue(playlist: Playlist) { + playbackManager.addToQueue(playlist.songs) + } + + /** + * Add [Song]s to the end of the queue. + * + * @param songs The [Song]s to add. + */ + fun addToQueue(songs: List) { + playbackManager.addToQueue(songs) } // --- STATUS FUNCTIONS --- @@ -467,34 +509,13 @@ constructor( */ fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) { viewModelScope.launch { - val library = musicRepository.library - if (library != null) { - val savedState = persistenceRepository.readState(library) - if (savedState != null) { - playbackManager.applySavedState(savedState, true) - onDone(true) - return@launch - } + val savedState = persistenceRepository.readState() + if (savedState != null) { + playbackManager.applySavedState(savedState, true) + onDone(true) + return@launch } onDone(false) } } - - /** - * Convert the given selection to a list of [Song]s. - * - * @param selection The selection of [Music] to convert. - * @return A [Song] list containing the child items of any [MusicParent] instances in the list - * alongside the unchanged [Song]s or the original selection. - */ - private fun selectionToSongs(selection: List): List { - return selection.flatMap { - when (it) { - is Album -> musicSettings.albumSongSort.songs(it.songs) - is Artist -> musicSettings.artistSongSort.songs(it.songs) - is Genre -> musicSettings.genreSongSort.songs(it.songs) - is Song -> listOf(it) - } - } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt index a61731213..545038207 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt @@ -26,7 +26,6 @@ import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase -import androidx.room.TypeConverter import androidx.room.TypeConverters import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.playback.state.RepeatMode @@ -40,7 +39,7 @@ import org.oxycblt.auxio.playback.state.RepeatMode entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class], version = 27, exportSchema = false) -@TypeConverters(PersistenceDatabase.Converters::class) +@TypeConverters(Music.UID.TypeConverters::class) abstract class PersistenceDatabase : RoomDatabase() { /** * Get the current [PlaybackStateDao]. @@ -55,14 +54,6 @@ abstract class PersistenceDatabase : RoomDatabase() { * @return A [QueueDao] providing control of the database's queue tables. */ abstract fun queueDao(): QueueDao - - object Converters { - /** @see [Music.UID.toString] */ - @TypeConverter fun fromMusicUID(uid: Music.UID?) = uid?.toString() - - /** @see [Music.UID.fromString] */ - @TypeConverter fun toMusicUid(string: String?) = string?.let(Music.UID::fromString) - } } /** @@ -135,6 +126,7 @@ interface QueueDao { suspend fun insertMapping(mapping: List) } +// TODO: Figure out how to get RepeatMode to map to an int instead of a string @Entity(tableName = PlaybackState.TABLE_NAME) data class PlaybackState( @PrimaryKey val id: Int, diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt index 9ce5d89d2..a246689fe 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt @@ -20,7 +20,7 @@ package org.oxycblt.auxio.playback.persist import javax.inject.Inject import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.model.Library +import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD @@ -32,12 +32,8 @@ import org.oxycblt.auxio.util.logE * @author Alexander Capehart (OxygenCobalt) */ interface PersistenceRepository { - /** - * Read the previously persisted [PlaybackStateManager.SavedState]. - * - * @param library The [Library] required to de-serialize the [PlaybackStateManager.SavedState]. - */ - suspend fun readState(library: Library): PlaybackStateManager.SavedState? + /** Read the previously persisted [PlaybackStateManager.SavedState]. */ + suspend fun readState(): PlaybackStateManager.SavedState? /** * Persist a new [PlaybackStateManager.SavedState]. @@ -49,10 +45,14 @@ interface PersistenceRepository { class PersistenceRepositoryImpl @Inject -constructor(private val playbackStateDao: PlaybackStateDao, private val queueDao: QueueDao) : - PersistenceRepository { +constructor( + private val playbackStateDao: PlaybackStateDao, + private val queueDao: QueueDao, + private val musicRepository: MusicRepository +) : PersistenceRepository { - override suspend fun readState(library: Library): PlaybackStateManager.SavedState? { + override suspend fun readState(): PlaybackStateManager.SavedState? { + val deviceLibrary = musicRepository.deviceLibrary ?: return null val playbackState: PlaybackState val heap: List val mapping: List @@ -73,14 +73,14 @@ constructor(private val playbackStateDao: PlaybackStateDao, private val queueDao shuffledMapping.add(entry.shuffledIndex) } - val parent = playbackState.parentUid?.let { library.find(it) } + val parent = playbackState.parentUid?.let { musicRepository.find(it) as? MusicParent } logD("Read playback state") return PlaybackStateManager.SavedState( parent = parent, queueState = Queue.SavedState( - heap.map { library.find(it.uid) }, + heap.map { deviceLibrary.findSong(it.uid) }, orderedMapping, shuffledMapping, playbackState.index, diff --git a/app/src/main/java/org/oxycblt/auxio/picker/ArtistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackChoiceAdapter.kt similarity index 56% rename from app/src/main/java/org/oxycblt/auxio/picker/ArtistChoiceAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackChoiceAdapter.kt index 5c9ad275b..be8bed183 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/ArtistChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackChoiceAdapter.kt @@ -1,6 +1,6 @@ /* - * Copyright (c) 2022 Auxio Project - * ArtistChoiceAdapter.kt is part of Auxio. + * Copyright (c) 2023 Auxio Project + * ArtistPlaybackChoiceAdapter.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,54 +16,44 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.picker +package org.oxycblt.auxio.playback.picker import android.view.View import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater /** - * An [RecyclerView.Adapter] that displays a list of [Artist] choices. + * A [FlexibleListAdapter] that displays a list of [Artist] playback choices, for use with + * [PlayFromArtistDialog]. * * @param listener A [ClickableListListener] to bind interactions to. - * @author OxygenCobalt. */ -class ArtistChoiceAdapter(private val listener: ClickableListListener) : - RecyclerView.Adapter() { - private var artists = listOf() - - override fun getItemCount() = artists.size - +class ArtistPlaybackChoiceAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter( + ArtistPlaybackChoiceViewHolder.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - ArtistChoiceViewHolder.from(parent) + ArtistPlaybackChoiceViewHolder.from(parent) - override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) = - holder.bind(artists[position], listener) - - /** - * Immediately update the [Artist] choices. - * - * @param newArtists The new [Artist]s to show. - */ - fun submitList(newArtists: List) { - if (newArtists != artists) { - artists = newArtists - @Suppress("NotifyDataSetChanged") notifyDataSetChanged() - } + override fun onBindViewHolder(holder: ArtistPlaybackChoiceViewHolder, position: Int) { + holder.bind(getItem(position), listener) } } /** * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Artist] item, for - * use with [ArtistChoiceAdapter]. Use [from] to create an instance. + * use [ArtistPlaybackChoiceAdapter]. Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) */ -class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : +class ArtistPlaybackChoiceViewHolder +private constructor(private val binding: ItemPickerChoiceBinding) : DialogRecyclerView.ViewHolder(binding.root) { /** * Bind new data to this instance. @@ -74,10 +64,11 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : fun bind(artist: Artist, listener: ClickableListListener) { listener.bind(artist, this) binding.pickerImage.bind(artist) - binding.pickerName.text = artist.resolveName(binding.context) + binding.pickerName.text = artist.name.resolve(binding.context) } companion object { + /** * Create a new instance. * @@ -85,6 +76,13 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : * @return A new instance. */ fun from(parent: View) = - ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) + ArtistPlaybackChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = + oldItem.name == newItem.name + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/picker/GenreChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackChoiceAdapter.kt similarity index 51% rename from app/src/main/java/org/oxycblt/auxio/picker/GenreChoiceAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackChoiceAdapter.kt index b01b42928..f5fdbf970 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/GenreChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackChoiceAdapter.kt @@ -1,6 +1,6 @@ /* - * Copyright (c) 2022 Auxio Project - * GenreChoiceAdapter.kt is part of Auxio. + * Copyright (c) 2023 Auxio Project + * GenrePlaybackChoiceAdapter.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,68 +16,59 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.picker +package org.oxycblt.auxio.playback.picker import android.view.View import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater /** - * An [RecyclerView.Adapter] that displays a list of [Genre] choices. + * A [FlexibleListAdapter] that displays a list of [Genre] playback choices, for use with + * [PlayFromGenreDialog]. * * @param listener A [ClickableListListener] to bind interactions to. - * @author OxygenCobalt. */ -class GenreChoiceAdapter(private val listener: ClickableListListener) : - RecyclerView.Adapter() { - private var genres = listOf() - - override fun getItemCount() = genres.size - +class GenrePlaybackChoiceAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter( + GenrePlaybackChoiceViewHolder.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - GenreChoiceViewHolder.from(parent) + GenrePlaybackChoiceViewHolder.from(parent) - override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) = - holder.bind(genres[position], listener) - - /** - * Immediately update the [Genre] choices. - * - * @param newGenres The new [Genre]s to show. - */ - fun submitList(newGenres: List) { - if (newGenres != genres) { - genres = newGenres - @Suppress("NotifyDataSetChanged") notifyDataSetChanged() - } + override fun onBindViewHolder(holder: GenrePlaybackChoiceViewHolder, position: Int) { + holder.bind(getItem(position), listener) } } /** * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Genre] item, for - * use with [GenreChoiceAdapter]. Use [from] to create an instance. + * use [GenrePlaybackChoiceAdapter]. Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) */ -class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : +class GenrePlaybackChoiceViewHolder +private constructor(private val binding: ItemPickerChoiceBinding) : DialogRecyclerView.ViewHolder(binding.root) { /** * Bind new data to this instance. * - * @param genre The new [Genre] to bind. + * @param artist The new [Genre] to bind. * @param listener A [ClickableListListener] to bind interactions to. */ - fun bind(genre: Genre, listener: ClickableListListener) { - listener.bind(genre, this) - binding.pickerImage.bind(genre) - binding.pickerName.text = genre.resolveName(binding.context) + fun bind(artist: Genre, listener: ClickableListListener) { + listener.bind(artist, this) + binding.pickerImage.bind(artist) + binding.pickerName.text = artist.name.resolve(binding.context) } companion object { + /** * Create a new instance. * @@ -85,6 +76,13 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : * @return A new instance. */ fun from(parent: View) = - GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) + GenrePlaybackChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: Genre, newItem: Genre) = + oldItem.name == newItem.name + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt new file mode 100644 index 000000000..0d477bd8d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 Auxio Project + * PlayFromArtistDialog.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.playback.picker + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * A picker [ViewBindingDialogFragment] intended for when [Artist] playback is ambiguous. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class PlayFromArtistDialog : + ViewBindingDialogFragment(), ClickableListListener { + private val playbackModel: PlaybackViewModel by activityViewModels() + private val pickerModel: PlaybackPickerViewModel by viewModels() + // Information about what Song to show choices for is initially within the navigation arguments + // as UIDs, as that is the only safe way to parcel a Song. + private val args: PlayFromArtistDialogArgs by navArgs() + private val choiceAdapter = ArtistPlaybackChoiceAdapter(this) + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.lbl_artists).setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogMusicChoicesBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMusicChoicesBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.choiceRecycler.apply { + itemAnimator = null + adapter = choiceAdapter + } + + pickerModel.setPickerSongUid(args.artistUid) + collectImmediately(pickerModel.currentPickerSong) { + if (it != null) { + choiceAdapter.update(it.artists, UpdateInstructions.Replace(0)) + } else { + findNavController().navigateUp() + } + } + } + + override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { + super.onDestroyBinding(binding) + choiceAdapter + } + + override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { + // User made a choice, play the given song from that artist. + val song = unlikelyToBeNull(pickerModel.currentPickerSong.value) + playbackModel.playFromArtist(song, item) + findNavController().navigateUp() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt similarity index 62% rename from app/src/main/java/org/oxycblt/auxio/picker/GenrePlaybackPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt index 17441f9a9..0b8914dc2 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/GenrePlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2022 Auxio Project - * GenrePlaybackPickerDialog.kt is part of Auxio. + * PlayFromGenreDialog.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.picker +package org.oxycblt.auxio.playback.picker import android.os.Bundle import android.view.LayoutInflater @@ -28,14 +28,13 @@ import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.DialogMusicPickerBinding +import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.requireIs import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -44,45 +43,49 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class GenrePlaybackPickerDialog : - ViewBindingDialogFragment(), ClickableListListener { - private val pickerModel: PickerViewModel by viewModels() +class PlayFromGenreDialog : + ViewBindingDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() + private val pickerModel: PlaybackPickerViewModel by viewModels() // Information about what Song to show choices for is initially within the navigation arguments // as UIDs, as that is the only safe way to parcel a Song. - private val args: GenrePlaybackPickerDialogArgs by navArgs() - // Okay to leak this since the Listener will not be called until after initialization. - private val genreAdapter = GenreChoiceAdapter(@Suppress("LeakingThis") this) - - override fun onCreateBinding(inflater: LayoutInflater) = - DialogMusicPickerBinding.inflate(inflater) + private val args: PlayFromGenreDialogArgs by navArgs() + private val choiceAdapter = GenrePlaybackChoiceAdapter(this) override fun onConfigDialog(builder: AlertDialog.Builder) { builder.setTitle(R.string.lbl_genres).setNegativeButton(R.string.lbl_cancel, null) } - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { - binding.pickerRecycler.adapter = genreAdapter + override fun onCreateBinding(inflater: LayoutInflater) = + DialogMusicChoicesBinding.inflate(inflater) - pickerModel.setItemUid(args.itemUid) - collectImmediately(pickerModel.genreChoices) { genres -> - if (genres.isNotEmpty()) { - // Make sure the genre choices align with any changes in the music library. - genreAdapter.submitList(genres) + override fun onBindingCreated(binding: DialogMusicChoicesBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.choiceRecycler.apply { + itemAnimator = null + adapter = choiceAdapter + } + + pickerModel.setPickerSongUid(args.genreUid) + collectImmediately(pickerModel.currentPickerSong) { + if (it != null) { + choiceAdapter.update(it.genres, UpdateInstructions.Replace(0)) } else { - // Not showing any choices, navigate up. findNavController().navigateUp() } } } - override fun onDestroyBinding(binding: DialogMusicPickerBinding) { - binding.pickerRecycler.adapter = null + override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { + super.onDestroyBinding(binding) + choiceAdapter } override fun onClick(item: Genre, viewHolder: RecyclerView.ViewHolder) { // User made a choice, play the given song from that genre. - val song = requireIs(unlikelyToBeNull(pickerModel.currentItem.value)) + val song = unlikelyToBeNull(pickerModel.currentPickerSong.value) playbackModel.playFromGenre(song, item) + findNavController().navigateUp() } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt new file mode 100644 index 000000000..577b93c50 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaybackPickerViewModel.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.playback.picker + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.music.* + +/** + * A [ViewModel] that stores the choices shown in the playback picker dialogs. + * + * @author OxygenCobalt (Alexander Capehart) + */ +@HiltViewModel +class PlaybackPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : + ViewModel(), MusicRepository.UpdateListener { + private val _currentPickerSong = MutableStateFlow(null) + /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ + val currentPickerSong: StateFlow + get() = _currentPickerSong + + init { + musicRepository.addUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.deviceLibrary) return + val deviceLibrary = musicRepository.deviceLibrary ?: return + _currentPickerSong.value = _currentPickerSong.value?.run { deviceLibrary.findSong(uid) } + } + + override fun onCleared() { + super.onCleared() + musicRepository.removeUpdateListener(this) + } + + /** + * Set the [Music.UID] of the [Song] to show choices for. + * + * @param uid The [Music.UID] of the item to show. Must be a [Song]. + */ + fun setPickerSongUid(uid: Music.UID) { + _currentPickerSong.value = musicRepository.deviceLibrary?.findSong(uid) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt index 1ccf3b4ab..434e8f479 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt @@ -306,7 +306,7 @@ class EditableQueue : Queue { else -> Queue.Change.Type.MAPPING } check() - return Queue.Change(type, UpdateInstructions.Remove(at)) + return Queue.Change(type, UpdateInstructions.Remove(at, 1)) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index 2230fe7a2..76625a038 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -26,9 +26,10 @@ import androidx.core.view.isInvisible import androidx.recyclerview.widget.RecyclerView import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.ItemQueueSongBinding -import org.oxycblt.auxio.list.EditableListListener +import org.oxycblt.auxio.databinding.ItemEditableSongBinding +import org.oxycblt.auxio.list.EditClickListListener import org.oxycblt.auxio.list.adapter.* +import org.oxycblt.auxio.list.recycler.MaterialDragCallback import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames @@ -37,10 +38,10 @@ import org.oxycblt.auxio.util.* /** * A [RecyclerView.Adapter] that shows an editable list of queue items. * - * @param listener A [EditableListListener] to bind interactions to. + * @param listener A [EditClickListListener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class QueueAdapter(private val listener: EditableListListener) : +class QueueAdapter(private val listener: EditClickListListener) : FlexibleListAdapter(QueueSongViewHolder.DIFF_CALLBACK) { // Since PlayingIndicator adapter relies on an item value, we cannot use it for this // adapter, as one item can appear at several points in the UI. Use a similar implementation @@ -96,34 +97,27 @@ class QueueAdapter(private val listener: EditableListListener) : } /** - * A [PlayingIndicatorAdapter.ViewHolder] that displays a queue [Song]. Use [from] to create an - * instance. + * A [PlayingIndicatorAdapter.ViewHolder] that displays an queue [Song] which can be re-ordered and + * removed. Use [from] to create an instance. * * @author Alexander Capehart (OxygenCobalt) */ -class QueueSongViewHolder private constructor(private val binding: ItemQueueSongBinding) : - PlayingIndicatorAdapter.ViewHolder(binding.root) { - /** The "body" view of this [QueueSongViewHolder] that shows the [Song] information. */ - val bodyView: View - get() = binding.body - - /** The background view of this [QueueSongViewHolder] that shows the delete icon. */ - val backgroundView: View - get() = binding.background - - /** The actual background drawable of this [QueueSongViewHolder] that can be manipulated. */ - val backgroundDrawable = +class QueueSongViewHolder private constructor(private val binding: ItemEditableSongBinding) : + PlayingIndicatorAdapter.ViewHolder(binding.root), MaterialDragCallback.ViewHolder { + override val enabled = true + 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) * 5 alpha = 0 } - /** If this queue item is considered "in the future" (i.e has not played yet). */ var isFuture: Boolean get() = binding.songAlbumCover.isEnabled set(value) { - // Don't want to disable clicking, just indicate the body and handle is disabled binding.songAlbumCover.isEnabled = value binding.songName.isEnabled = value binding.songInfo.isEnabled = value @@ -137,20 +131,20 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) elevation = binding.context.getDimen(R.dimen.elevation_normal) }, - backgroundDrawable)) + background)) } /** * Bind new data to this instance. * * @param song The new [Song] to bind. - * @param listener A [EditableListListener] to bind interactions to. + * @param listener A [EditClickListListener] to bind interactions to. */ @SuppressLint("ClickableViewAccessibility") - fun bind(song: Song, listener: EditableListListener) { - listener.bind(song, this, bodyView, binding.songDragHandle) + fun bind(song: Song, listener: EditClickListListener) { + listener.bind(song, this, body, binding.songDragHandle) 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) // Not swiping this ViewHolder if it's being re-bound, ensure that the background is // not visible. See QueueDragCallback for why this is done. @@ -170,7 +164,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong * @return A new instance. */ fun from(parent: View) = - QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater)) + QueueSongViewHolder(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/playback/queue/QueueDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt index 5b61eb7c4..23d45f62a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt @@ -18,15 +18,9 @@ package org.oxycblt.auxio.playback.queue -import android.graphics.Canvas -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 +import org.oxycblt.auxio.list.recycler.MaterialDragCallback /** * A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in the queue UI, @@ -34,108 +28,16 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() { - private var shouldLift = true - - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) = - makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or - makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START) - - override fun onChildDraw( - c: Canvas, - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - dX: Float, - dY: Float, - actionState: Int, - isCurrentlyActive: Boolean - ) { - val holder = viewHolder as QueueSongViewHolder - - // Hook drag events to "lifting" the queue 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 queue item") - - val bg = holder.backgroundDrawable - val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal) - holder.itemView - .animate() - .translationZ(elevation) - .setDuration( - recyclerView.context.getInteger(R.integer.anim_fade_exit_duration).toLong()) - .setUpdateListener { - bg.alpha = ((holder.itemView.translationZ / elevation) * 255).toInt() - } - .setInterpolator(AccelerateDecelerateInterpolator()) - .start() - - shouldLift = false - } - - // We show a background with a delete icon behind the queue song each time one is swiped - // away. To avoid working with canvas, this is simply placed behind the queue 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 queue item. - if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { - holder.backgroundView.isInvisible = dX == 0f - } - - // Update other translations. We do not call the default implementation, so we must do - // this ourselves. - holder.bodyView.translationX = dX - holder.itemView.translationY = dY - } - - 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 QueueSongViewHolder - - // This function can be called multiple times, so only start the animation when the view's - // translationZ is already non-zero. - if (holder.itemView.translationZ != 0f) { - logD("Dropping queue item") - - val bg = holder.backgroundDrawable - val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal) - holder.itemView - .animate() - .translationZ(0f) - .setDuration( - recyclerView.context.getInteger(R.integer.anim_fade_exit_duration).toLong()) - .setUpdateListener { - bg.alpha = ((holder.itemView.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.bodyView.translationX = 0f - holder.itemView.translationY = 0f - } - +class QueueDragCallback(private val queueModel: QueueViewModel) : MaterialDragCallback() { override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder - ): Boolean { - logD("${viewHolder.bindingAdapterPosition} ${target.bindingAdapterPosition}") - return playbackModel.moveQueueDataItems( + ) = + queueModel.moveQueueDataItems( viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) - } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - playbackModel.removeQueueDataItem(viewHolder.bindingAdapterPosition) + queueModel.removeQueueDataItem(viewHolder.bindingAdapterPosition) } - - // Long-press events are too buggy, only allow dragging with the handle. - override fun isLongPressDragEnabled() = false } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index e39348451..414ab0eeb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -28,7 +28,7 @@ import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentQueueBinding -import org.oxycblt.auxio.list.EditableListListener +import org.oxycblt.auxio.list.EditClickListListener import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment @@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class QueueFragment : ViewBindingFragment(), EditableListListener { +class QueueFragment : ViewBindingFragment(), EditClickListListener { private val queueModel: QueueViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels() private val queueAdapter = QueueAdapter(this) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index e9015c8d5..7bb57376c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -18,12 +18,12 @@ package org.oxycblt.auxio.playback.replaygain -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.Format -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.Tracks -import com.google.android.exoplayer2.audio.AudioProcessor -import com.google.android.exoplayer2.audio.BaseAudioProcessor +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Player +import androidx.media3.common.Tracks +import androidx.media3.common.audio.AudioProcessor +import androidx.media3.exoplayer.audio.BaseAudioProcessor import java.nio.ByteBuffer import javax.inject.Inject import kotlin.math.pow @@ -294,6 +294,6 @@ constructor( * Matches non-float information from ReplayGain adjustments. Derived from vanilla music: * https://github.com/vanilla-music/vanilla */ - val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX = Regex("[^\\d.-]") + val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX by lazy { Regex("[^\\d.-]") } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt index 3edbf8633..17186e181 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt @@ -75,22 +75,22 @@ interface InternalPlayer { fun setPlaying(isPlaying: Boolean) /** Possible long-running background tasks handled by the background playback task. */ - sealed class Action { + sealed interface Action { /** Restore the previously saved playback state. */ - object RestoreState : Action() + object RestoreState : Action /** * Start shuffled playback of the entire music library. Analogous to the "Shuffle All" * shortcut. */ - object ShuffleAll : Action() + object ShuffleAll : Action /** * Start playing an audio file at the given [Uri]. * * @param uri The [Uri] of the audio file to start playing. */ - data class Open(val uri: Uri) : Action() + data class Open(val uri: Uri) : Action } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index c15982e03..63fb85ed2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -534,17 +534,24 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val internalPlayer = internalPlayer ?: return logD("Restoring state $savedState") + val lastSong = queue.currentSong parent = savedState.parent queue.applySavedState(savedState.queueState) repeatMode = savedState.repeatMode notifyNewPlayback() - // Continuing playback while also possibly doing drastic state updates is - // a bad idea, so pause. - internalPlayer.loadSong(queue.currentSong, false) - if (queue.currentSong != null) { - // Internal player may have reloaded the media item, re-seek to the previous position - seekTo(savedState.positionMs) + // Check if we need to reload the player with a new music file, or if we can just leave + // it be. Specifically done so we don't pause on music updates that don't really change + // what's playing (ex. playlist editing) + if (lastSong != queue.currentSong) { + // Continuing playback while also possibly doing drastic state updates is + // a bad idea, so pause. + internalPlayer.loadSong(queue.currentSong, false) + if (queue.currentSong != null) { + // Internal player may have reloaded the media item, re-seek to the previous + // position + seekTo(savedState.positionMs) + } } isInitialized = true } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index 6194839f5..1d44ebc46 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -30,7 +30,6 @@ import android.support.v4.media.session.PlaybackStateCompat import androidx.media.session.MediaButtonReceiver import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings @@ -55,9 +54,10 @@ class MediaSessionComponent @Inject constructor( @ApplicationContext private val context: Context, - private val bitmapProvider: BitmapProvider, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, + private val bitmapProvider: BitmapProvider, + private val imageSettings: ImageSettings ) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener, @@ -76,6 +76,7 @@ constructor( init { playbackManager.addListener(this) playbackSettings.registerListener(this) + imageSettings.registerListener(this) mediaSession.setCallback(this) } @@ -105,6 +106,7 @@ constructor( listener = null bitmapProvider.release() playbackSettings.unregisterListener(this) + imageSettings.unregisterListener(this) playbackManager.removeListener(this) mediaSession.apply { isActive = false @@ -289,12 +291,12 @@ constructor( // Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used // several times. - val title = song.resolveName(context) + val title = song.name.resolve(context) val artist = song.artists.resolveNames(context) val builder = MediaMetadataCompat.Builder() .putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context)) + .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name.resolve(context)) // Note: We would leave the artist field null if it didn't exist and let downstream // consumers handle it, but that would break the notification display. .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) @@ -304,15 +306,13 @@ constructor( .putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist) .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist) - .putText( - METADATA_KEY_PARENT, - parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)) .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genres.resolveNames(context)) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) .putText( MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, - parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)) + parent?.run { name.resolve(context) } + ?: context.getString(R.string.lbl_all_songs)) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs) // These fields are nullable and so we must check first before adding them to the fields. song.track?.let { @@ -353,7 +353,7 @@ constructor( // Media ID should not be the item index but rather the UID, // as it's used to request a song to be played from the queue. .setMediaId(song.uid.toString()) - .setTitle(song.resolveName(context)) + .setTitle(song.name.resolve(context)) .setSubtitle(song.artists.resolveNames(context)) // Since we usually have to load many songs into the queue, use the // MediaStore URI instead of loading a bitmap. @@ -438,11 +438,6 @@ constructor( } companion object { - /** - * An extended metadata key that stores the resolved name of the [MusicParent] that is - * currently being played from. - */ - const val METADATA_KEY_PARENT = BuildConfig.APPLICATION_ID + ".metadata.PARENT" private val emptyMetadata = MediaMetadataCompat.Builder().build() private const val ACTIONS = PlaybackStateCompat.ACTION_PLAY or diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 4dd18fb85..18c948326 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -26,18 +26,14 @@ import android.content.IntentFilter import android.media.AudioManager import android.media.audiofx.AudioEffect import android.os.IBinder -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.PlaybackException -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.RenderersFactory -import com.google.android.exoplayer2.audio.AudioAttributes -import com.google.android.exoplayer2.audio.AudioCapabilities -import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer -import com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer -import com.google.android.exoplayer2.mediacodec.MediaCodecSelector -import com.google.android.exoplayer2.source.MediaSource +import androidx.media3.common.* +import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.audio.AudioCapabilities +import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.source.MediaSource import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -48,7 +44,6 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.model.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor @@ -82,7 +77,7 @@ class PlaybackService : Player.Listener, InternalPlayer, MediaSessionComponent.Listener, - MusicRepository.Listener { + MusicRepository.UpdateListener { // Player components private lateinit var player: ExoPlayer @Inject lateinit var mediaSourceFactory: MediaSource.Factory @@ -148,7 +143,7 @@ class PlaybackService : // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. playbackManager.registerInternalPlayer(this) - musicRepository.addListener(this) + musicRepository.addUpdateListener(this) mediaSessionComponent.registerListener(this) registerReceiver( systemReceiver, @@ -187,7 +182,7 @@ class PlaybackService : // Pause just in case this destruction was unexpected. playbackManager.setPlaying(false) playbackManager.unregisterInternalPlayer(this) - musicRepository.removeListener(this) + musicRepository.removeUpdateListener(this) unregisterReceiver(systemReceiver) serviceJob.cancel() @@ -232,7 +227,7 @@ class PlaybackService : return } - logD("Loading ${song.rawName}") + logD("Loading ${song.name}") player.setMediaItem(MediaItem.fromUri(song.uri)) player.prepare() player.playWhenReady = play @@ -299,10 +294,8 @@ class PlaybackService : playbackManager.next() } - // --- MUSICSTORE OVERRIDES --- - - override fun onLibraryChanged(library: Library?) { - if (library != null) { + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { // We now have a library, see if we have anything we need to do. playbackManager.requestAction(this) } @@ -331,8 +324,8 @@ class PlaybackService : } override fun performAction(action: InternalPlayer.Action): Boolean { - val library = - musicRepository.library + val deviceLibrary = + musicRepository.deviceLibrary // No library, cannot do anything. ?: return false @@ -342,22 +335,23 @@ class PlaybackService : // Restore state -> Start a new restoreState job is InternalPlayer.Action.RestoreState -> { restoreScope.launch { - persistenceRepository.readState(library)?.let { + persistenceRepository.readState()?.let { playbackManager.applySavedState(it, false) } } } // Shuffle all -> Start new playback from all songs is InternalPlayer.Action.ShuffleAll -> { - playbackManager.play(null, null, musicSettings.songSort.songs(library.songs), true) + playbackManager.play( + null, null, musicSettings.songSort.songs(deviceLibrary.songs), true) } // Open -> Try to find the Song for the given file and then play it from all songs is InternalPlayer.Action.Open -> { - library.findSongForUri(application, action.uri)?.let { song -> + deviceLibrary.findSongForUri(application, action.uri)?.let { song -> playbackManager.play( song, null, - musicSettings.songSort.songs(library.songs), + musicSettings.songSort.songs(deviceLibrary.songs), playbackManager.queue.isShuffled && playbackSettings.keepShuffle) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/SystemModule.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/SystemModule.kt new file mode 100644 index 000000000..47b052761 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/SystemModule.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 Auxio Project + * SystemModule.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.playback.system + +import android.content.Context +import androidx.media3.datasource.ContentDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.flac.FlacExtractor +import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.mp3.Mp3Extractor +import androidx.media3.extractor.mp4.FragmentedMp4Extractor +import androidx.media3.extractor.mp4.Mp4Extractor +import androidx.media3.extractor.ogg.OggExtractor +import androidx.media3.extractor.ts.AdtsExtractor +import androidx.media3.extractor.wav.WavExtractor +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class SystemModule { + @Provides + fun mediaSourceFactory( + dataSourceFactory: DataSource.Factory, + extractorsFactory: ExtractorsFactory + ): MediaSource.Factory = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + + @Provides + fun dataSourceFactory(@ApplicationContext context: Context) = + // We only ever open conte tURIs, so only provide those data sources. + DataSource.Factory { ContentDataSource(context) } + + @Provides + fun extractorsFactory() = ExtractorsFactory { + // Define our own extractors so we can exclude non-audio parsers. + // Ordering is derived from the DefaultExtractorsFactory's optimized ordering: + // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. + arrayOf( + FlacExtractor(), + WavExtractor(), + FragmentedMp4Extractor(), + Mp4Extractor(), + OggExtractor(), + MatroskaExtractor(), + // Enable constant bitrate seeking so that certain MP3s/AACs are seekable + AdtsExtractor(AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING), + Mp3Extractor(Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING)) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index 6957cf2fa..4c1b2c2a7 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -34,8 +34,7 @@ import org.oxycblt.auxio.util.logD * @author Alexander Capehart (OxygenCobalt) */ class SearchAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter(DIFF_CALLBACK), - AuxioRecyclerView.SpanSizeLookup { + SelectionIndicatorAdapter(DIFF_CALLBACK) { override fun getItemViewType(position: Int) = when (getItem(position)) { @@ -43,6 +42,8 @@ class SearchAdapter(private val listener: SelectableListListener) : is Album -> AlbumViewHolder.VIEW_TYPE is Artist -> ArtistViewHolder.VIEW_TYPE is Genre -> GenreViewHolder.VIEW_TYPE + is Playlist -> PlaylistViewHolder.VIEW_TYPE + is Divider -> DividerViewHolder.VIEW_TYPE is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE else -> super.getItemViewType(position) } @@ -53,6 +54,8 @@ class SearchAdapter(private val listener: SelectableListListener) : AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.from(parent) ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent) GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent) + PlaylistViewHolder.VIEW_TYPE -> PlaylistViewHolder.from(parent) + DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent) BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) else -> error("Invalid item type $viewType") } @@ -64,12 +67,11 @@ class SearchAdapter(private val listener: SelectableListListener) : is Album -> (holder as AlbumViewHolder).bind(item, listener) is Artist -> (holder as ArtistViewHolder).bind(item, listener) is Genre -> (holder as GenreViewHolder).bind(item, listener) + is Playlist -> (holder as PlaylistViewHolder).bind(item, listener) is BasicHeader -> (holder as BasicHeaderViewHolder).bind(item) } } - override fun isItemFullWidth(position: Int) = getItem(position) is BasicHeader - private companion object { /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = @@ -84,6 +86,10 @@ class SearchAdapter(private val listener: SelectableListListener) : ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Genre && newItem is Genre -> GenreViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + oldItem is Playlist && newItem is Playlist -> + PlaylistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + oldItem is Divider && newItem is Divider -> + DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is BasicHeader && newItem is BasicHeader -> BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) else -> false diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index e3733549e..ee83b5418 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -26,7 +26,9 @@ 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.Playlist import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.info.Name /** * Implements the fuzzy-ish searching algorithm used in the search view. @@ -50,12 +52,14 @@ interface SearchEngine { * @param albums A list of [Album]s, null if empty. * @param artists A list of [Artist]s, null if empty. * @param genres A list of [Genre]s, null if empty. + * @param playlists A list of [Playlist], null if empty. */ data class Items( val songs: List?, val albums: List?, val artists: List?, - val genres: List? + val genres: List?, + val playlists: List? ) } @@ -63,10 +67,14 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte SearchEngine { override suspend fun search(items: SearchEngine.Items, query: String) = SearchEngine.Items( - songs = items.songs?.searchListImpl(query) { q, song -> song.path.name.contains(q) }, + songs = + items.songs?.searchListImpl(query) { q, song -> + song.path.name.contains(q, ignoreCase = true) + }, albums = items.albums?.searchListImpl(query), artists = items.artists?.searchListImpl(query), - genres = items.genres?.searchListImpl(query)) + genres = items.genres?.searchListImpl(query), + playlists = items.playlists?.searchListImpl(query)) /** * Search a given [Music] list. @@ -84,17 +92,21 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte filter { // See if the plain resolved name matches the query. This works for most // situations. - val name = it.resolveName(context) - if (name.contains(query, ignoreCase = true)) { + val name = it.name + + val resolvedName = name.resolve(context) + if (resolvedName.contains(query, ignoreCase = true)) { return@filter true } // See if the sort name matches. This can sometimes be helpful as certain // libraries // will tag sort names to have a alphabetized version of the title. - val sortName = it.rawSortName - if (sortName != null && sortName.contains(query, ignoreCase = true)) { - return@filter true + if (name is Name.Known) { + val sortName = name.sort + if (sortName != null && sortName.contains(query, ignoreCase = true)) { + return@filter true + } } // As a last-ditch effort, see if the normalized name matches. This will replace @@ -103,7 +115,7 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte // could make it match the query. val normalizedName = NORMALIZE_POST_PROCESSING_REGEX.replace( - Normalizer.normalize(name, Normalizer.Form.NFKD), "") + Normalizer.normalize(resolvedName, Normalizer.Form.NFKD), "") if (normalizedName.contains(query, ignoreCase = true)) { return@filter true } @@ -117,7 +129,8 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte * Converts the output of [Normalizer] to remove any junk characters added by it's * replacements, alongside punctuation. */ - val NORMALIZE_POST_PROCESSING_REGEX = + val NORMALIZE_POST_PROCESSING_REGEX by lazy { Regex("(\\p{InCombiningDiacriticalMarks}+)|(\\p{Punct})") + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 35ff34079..a7b29b204 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -29,21 +29,19 @@ import androidx.core.widget.addTextChangedListener import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentSearchBinding +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.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.* /** @@ -58,6 +56,7 @@ import org.oxycblt.auxio.util.* class SearchFragment : ListFragment() { 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 searchModel: SearchViewModel by viewModels() private val searchAdapter = SearchAdapter(this) @@ -82,7 +81,7 @@ class SearchFragment : ListFragment() { imm = binding.context.getSystemServiceCompat(InputMethodManager::class) - binding.searchToolbar.apply { + binding.searchNormalToolbar.apply { // Initialize the current filtering mode. menu.findItem(searchModel.getFilterOptionId()).isChecked = true @@ -108,7 +107,16 @@ class SearchFragment : ListFragment() { } } - binding.searchRecycler.adapter = searchAdapter + binding.searchRecycler.apply { + adapter = searchAdapter + (layoutManager as GridLayoutManager).setFullWidthLookup { + val item = + searchModel.searchResults.value.getOrElse(it) { + return@setFullWidthLookup false + } + item is Divider || item is Header + } + } // --- VIEWMODEL SETUP --- @@ -121,7 +129,7 @@ class SearchFragment : ListFragment() { override fun onDestroyBinding(binding: FragmentSearchBinding) { super.onDestroyBinding(binding) - binding.searchToolbar.setOnMenuItemClickListener(null) + binding.searchNormalToolbar.setOnMenuItemClickListener(null) binding.searchRecycler.adapter = null } @@ -153,8 +161,9 @@ class SearchFragment : ListFragment() { when (item) { is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item) - is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item) - is Genre -> openMusicMenu(anchor, R.menu.menu_artist_actions, item) + is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) + is Genre -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) + is Playlist -> openMusicMenu(anchor, R.menu.menu_playlist_actions, item) } } @@ -182,7 +191,8 @@ class SearchFragment : ListFragment() { is Album -> SearchFragmentDirections.actionShowAlbum(item.uid) is Artist -> SearchFragmentDirections.actionShowArtist(item.uid) is Genre -> SearchFragmentDirections.actionShowGenre(item.uid) - else -> return + is Playlist -> SearchFragmentDirections.actionShowPlaylist(item.uid) + null -> return } // Keyboard is no longer needed. hideKeyboard() @@ -191,10 +201,16 @@ class SearchFragment : ListFragment() { private fun updateSelection(selected: List) { searchAdapter.setSelected(selected.toSet()) - if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) && - selected.isNotEmpty()) { - // Make selection of obscured items easier by hiding the keyboard. - hideKeyboard() + val binding = requireBinding() + if (selected.isNotEmpty()) { + binding.searchSelectionToolbar.title = getString(R.string.fmt_selected, selected.size) + if (binding.searchToolbar.setVisible(R.id.search_selection_toolbar)) { + // New selection started, show the keyboard to make selection easier. + logD("Significant selection occurred, hiding keyboard") + hideKeyboard() + } + } else { + binding.searchToolbar.setVisible(R.id.search_normal_toolbar) } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 4af58703c..ec42ca3cb 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -30,10 +30,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.oxycblt.auxio.R 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.music.* -import org.oxycblt.auxio.music.model.Library +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD @@ -50,7 +52,7 @@ constructor( private val searchEngine: SearchEngine, private val searchSettings: SearchSettings, private val playbackSettings: PlaybackSettings, -) : ViewModel(), MusicRepository.Listener { +) : ViewModel(), MusicRepository.UpdateListener { private var lastQuery: String? = null private var currentSearchJob: Job? = null @@ -64,17 +66,16 @@ constructor( get() = playbackSettings.inListPlaybackMode init { - musicRepository.addListener(this) + musicRepository.addUpdateListener(this) } override fun onCleared() { super.onCleared() - musicRepository.removeListener(this) + musicRepository.removeUpdateListener(this) } - override fun onLibraryChanged(library: Library?) { - if (library != null) { - // Make sure our query is up to date with the music library. + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.deviceLibrary || changes.userLibrary) { search(lastQuery) } } @@ -90,8 +91,9 @@ constructor( currentSearchJob?.cancel() lastQuery = query - val library = musicRepository.library - if (query.isNullOrEmpty() || library == null) { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (query.isNullOrEmpty() || deviceLibrary == null || userLibrary == null) { logD("Search query is not applicable.") _searchResults.value = listOf() return @@ -102,43 +104,80 @@ constructor( // Searching is time-consuming, so do it in the background. currentSearchJob = viewModelScope.launch { - _searchResults.value = searchImpl(library, query).also { yield() } + _searchResults.value = + searchImpl(deviceLibrary, userLibrary, query).also { yield() } } } - private suspend fun searchImpl(library: Library, query: String): List { + private suspend fun searchImpl( + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary, + query: String + ): List { val filterMode = searchSettings.searchFilterMode val items = if (filterMode == null) { // A nulled filter mode means to not filter anything. - SearchEngine.Items(library.songs, library.albums, library.artists, library.genres) + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists) } else { SearchEngine.Items( - songs = if (filterMode == MusicMode.SONGS) library.songs else null, - albums = if (filterMode == MusicMode.ALBUMS) library.albums else null, - artists = if (filterMode == MusicMode.ARTISTS) library.artists else null, - genres = if (filterMode == MusicMode.GENRES) library.genres else null) + songs = if (filterMode == MusicMode.SONGS) deviceLibrary.songs else null, + albums = if (filterMode == MusicMode.ALBUMS) deviceLibrary.albums else null, + artists = if (filterMode == MusicMode.ARTISTS) deviceLibrary.artists else null, + genres = if (filterMode == MusicMode.GENRES) deviceLibrary.genres else null, + playlists = + if (filterMode == MusicMode.PLAYLISTS) userLibrary.playlists else null) } val results = searchEngine.search(items, query) return buildList { - results.artists?.let { artists -> - add(BasicHeader(R.string.lbl_artists)) - addAll(SORT.artists(artists)) + results.artists?.let { + val header = BasicHeader(R.string.lbl_artists) + add(header) + addAll(SORT.artists(it)) } - results.albums?.let { albums -> - add(BasicHeader(R.string.lbl_albums)) - addAll(SORT.albums(albums)) + results.albums?.let { + val header = BasicHeader(R.string.lbl_albums) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) + addAll(SORT.albums(it)) } - results.genres?.let { genres -> - add(BasicHeader(R.string.lbl_genres)) - addAll(SORT.genres(genres)) + results.playlists?.let { + val header = BasicHeader(R.string.lbl_playlists) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) + addAll(SORT.playlists(it)) } - results.songs?.let { songs -> - add(BasicHeader(R.string.lbl_songs)) - addAll(SORT.songs(songs)) + results.genres?.let { + val header = BasicHeader(R.string.lbl_genres) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) + addAll(SORT.genres(it)) + } + results.songs?.let { + val header = BasicHeader(R.string.lbl_songs) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) + addAll(SORT.songs(it)) } } } @@ -155,6 +194,7 @@ constructor( MusicMode.ALBUMS -> R.id.option_filter_albums MusicMode.ARTISTS -> R.id.option_filter_artists MusicMode.GENRES -> R.id.option_filter_genres + MusicMode.PLAYLISTS -> R.id.option_filter_playlists // Null maps to filtering nothing. null -> R.id.option_filter_all } @@ -171,6 +211,7 @@ constructor( R.id.option_filter_albums -> MusicMode.ALBUMS R.id.option_filter_artists -> MusicMode.ARTISTS R.id.option_filter_genres -> MusicMode.GENRES + R.id.option_filter_playlists -> MusicMode.PLAYLISTS // Null maps to filtering nothing. R.id.option_filter_all -> null else -> error("Invalid option ID provided") diff --git a/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt index 3579a4a5d..f739f6b29 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt @@ -109,7 +109,7 @@ abstract class BasePreferenceFragment(@XmlRes private val screen: Int) : // Copy the built-in preference dialog launching code into our project so // we can automatically use the provided preference class. val dialog = IntListPreferenceDialog.from(preference) - @Suppress("Deprecation") dialog.setTargetFragment(this, 0) + dialog.setTargetFragment(this, 0) dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG) } is WrappedDialogPreference -> { diff --git a/app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt b/app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt new file mode 100644 index 000000000..657b5c6ca --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023 Auxio Project + * MultiToolbar.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.ui + +import android.animation.ValueAnimator +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.annotation.AttrRes +import androidx.annotation.IdRes +import androidx.appcompat.widget.Toolbar +import androidx.core.view.children +import androidx.core.view.isInvisible +import org.oxycblt.auxio.R +import org.oxycblt.auxio.util.getInteger +import org.oxycblt.auxio.util.logD + +class MultiToolbar +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : + FrameLayout(context, attrs, defStyleAttr) { + private var fadeThroughAnimator: ValueAnimator? = null + private var currentlyVisible = 0 + + override fun onFinishInflate() { + super.onFinishInflate() + for (i in 1 until childCount) { + getChildAt(i).apply { + alpha = 0f + isInvisible = true + } + } + } + + fun setVisible(@IdRes viewId: Int): Boolean { + val index = children.indexOfFirst { it.id == viewId } + if (index == currentlyVisible) return false + return animateToolbarsVisibility(currentlyVisible, index).also { currentlyVisible = index } + } + + private fun animateToolbarsVisibility(from: Int, to: Int): 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 targetFromAlpha = 0f + val targetToAlpha = 1f + val targetDuration = + if (from < to) { + context.getInteger(R.integer.anim_fade_enter_duration).toLong() + } else { + context.getInteger(R.integer.anim_fade_exit_duration).toLong() + } + + logD(targetDuration) + + val fromView = getChildAt(from) as Toolbar + val toView = getChildAt(to) as Toolbar + + if (fromView.alpha == targetFromAlpha && toView.alpha == targetToAlpha) { + // 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(fromView, toView, targetFromAlpha) + return false + } + + if (fadeThroughAnimator != null) { + fadeThroughAnimator?.cancel() + fadeThroughAnimator = null + } + + fadeThroughAnimator = + ValueAnimator.ofFloat(fromView.alpha, targetFromAlpha).apply { + duration = targetDuration + addUpdateListener { setToolbarsAlpha(fromView, toView, it.animatedValue as Float) } + start() + } + + return true + } + + private fun setToolbarsAlpha(from: Toolbar, to: Toolbar, innerAlpha: Float) { + logD("${to.id == R.id.detail_edit_toolbar} ${1 - innerAlpha}") + from.apply { + alpha = innerAlpha + isInvisible = innerAlpha == 0f + } + + to.apply { + alpha = 1 - innerAlpha + isInvisible = innerAlpha == 1f + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index 9ec0caa75..71fc880d6 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -31,8 +31,10 @@ import androidx.core.graphics.Insets import androidx.core.graphics.drawable.DrawableCompat import androidx.navigation.NavController import androidx.navigation.NavDirections +import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding +import java.lang.IllegalArgumentException /** * Get if this [View] contains the given [PointF], with optional leeway. @@ -104,6 +106,20 @@ val ViewBinding.context: Context */ fun RecyclerView.canScroll() = computeVerticalScrollRange() > height +/** + * Shortcut to easily set up a [GridLayoutManager.SpanSizeLookup]. + * + * @param isItemFullWidth Mapping expression that returns true if the item should take up all spans + * or just one. + */ +fun GridLayoutManager.setFullWidthLookup(isItemFullWidth: (Int) -> Boolean) { + spanSizeLookup = + object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int) = + if (isItemFullWidth(position)) spanCount else 1 + } +} + /** * Fix the double ripple that appears in MaterialButton instances due to an issue with AppCompat 1.5 * or higher. @@ -124,8 +140,10 @@ fun AppCompatButton.fixDoubleRipple() { fun NavController.navigateSafe(directions: NavDirections) = try { navigate(directions) - } catch (e: IllegalStateException) { + } catch (e: IllegalArgumentException) { // Nothing to do. + logE("Could not navigate from this destination.") + logE(e.stackTraceToString()) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt index 047c24c21..51835d68c 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -18,9 +18,11 @@ package org.oxycblt.auxio.util +import java.security.MessageDigest import java.util.UUID import kotlin.reflect.KClass import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.music.info.Date /** * Sanitizes a value that is unlikely to be null. On debug builds, this aliases to [requireNotNull], @@ -34,18 +36,6 @@ fun unlikelyToBeNull(value: T?) = value!! } -/** - * Require that the given data is a specific type [T]. - * - * @param data The data to check. - * @return A data casted to [T]. - * @throws IllegalStateException If the data cannot be casted to [T]. - */ -inline fun requireIs(data: Any?): T { - check(data is T) { "Unexpected datatype: ${data?.let { it::class.simpleName }}" } - return data -} - /** * Aliases a check to ensure that the given number is non-zero. * @@ -101,3 +91,51 @@ fun String.toUuidOrNull(): UUID? = } catch (e: IllegalArgumentException) { null } + +/** + * Update a [MessageDigest] with a lowercase [String]. + * + * @param string The [String] to hash. If null, it will not be hashed. + */ +fun MessageDigest.update(string: String?) { + if (string != null) { + update(string.lowercase().toByteArray()) + } else { + update(0) + } +} + +/** + * Update a [MessageDigest] with the string representation of a [Date]. + * + * @param date The [Date] to hash. If null, nothing will be done. + */ +fun MessageDigest.update(date: Date?) { + if (date != null) { + update(date.toString().toByteArray()) + } else { + update(0) + } +} + +/** + * Update a [MessageDigest] with the lowercase versions of all of the input [String]s. + * + * @param strings The [String]s to hash. If a [String] is null, it will not be hashed. + */ +fun MessageDigest.update(strings: List) { + strings.forEach(::update) +} + +/** + * Update a [MessageDigest] with the little-endian bytes of a [Int]. + * + * @param n The [Int] to write. If null, nothing will be done. + */ +fun MessageDigest.update(n: Int?) { + if (n != null) { + update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte())) + } else { + update(0) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 3e9726263..88e3dffa0 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -248,7 +248,8 @@ class WidgetProvider : AppWidgetProvider() { setImageViewBitmap(R.id.widget_cover, state.cover) setContentDescription( R.id.widget_cover, - context.getString(R.string.desc_album_cover, state.song.album.resolveName(context))) + context.getString( + R.string.desc_album_cover, state.song.album.name.resolve(context))) } else { // We are unable to use the typical placeholder cover with the song item due to // limitations with the corner radius. Instead use a custom-made album icon as the @@ -272,7 +273,7 @@ class WidgetProvider : AppWidgetProvider() { state: WidgetComponent.PlaybackState ): RemoteViews { setupCover(context, state) - setTextViewText(R.id.widget_song, state.song.resolveName(context)) + setTextViewText(R.id.widget_song, state.song.name.resolve(context)) setTextViewText(R.id.widget_artist, state.song.artists.resolveNames(context)) return this } diff --git a/app/src/main/res/drawable-v23/ui_item_ripple.xml b/app/src/main/res/drawable-v23/ui_item_ripple.xml index f8f2d8917..8f0d43cfb 100644 --- a/app/src/main/res/drawable-v23/ui_item_ripple.xml +++ b/app/src/main/res/drawable-v23/ui_item_ripple.xml @@ -1,6 +1,5 @@ - - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_edit_24.xml b/app/src/main/res/drawable/ic_edit_24.xml new file mode 100644 index 000000000..9ce54759b --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_playing_indicator_24.xml b/app/src/main/res/drawable/ic_playing_indicator_24.xml index 31f56a07a..63c270ac1 100644 --- a/app/src/main/res/drawable/ic_playing_indicator_24.xml +++ b/app/src/main/res/drawable/ic_playing_indicator_24.xml @@ -3,9 +3,9 @@ xmlns:aapt="http://schemas.android.com/aapt"> diff --git a/app/src/main/res/drawable/ic_playlist_24.xml b/app/src/main/res/drawable/ic_playlist_24.xml new file mode 100644 index 000000000..d92e150b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_save_24.xml b/app/src/main/res/drawable/ic_save_24.xml new file mode 100644 index 000000000..4fc73a9f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_save_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/sel_item_ripple_bg.xml b/app/src/main/res/drawable/sel_selection_bg.xml similarity index 100% rename from app/src/main/res/drawable/sel_item_ripple_bg.xml rename to app/src/main/res/drawable/sel_selection_bg.xml diff --git a/app/src/main/res/drawable/ui_item_bg.xml b/app/src/main/res/drawable/ui_item_bg.xml new file mode 100644 index 000000000..fb0a9dec3 --- /dev/null +++ b/app/src/main/res/drawable/ui_item_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_item_ripple.xml b/app/src/main/res/drawable/ui_item_ripple.xml index 03fd102f4..10aa281e7 100644 --- a/app/src/main/res/drawable/ui_item_ripple.xml +++ b/app/src/main/res/drawable/ui_item_ripple.xml @@ -1,5 +1,4 @@ - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_delete_playlist.xml b/app/src/main/res/layout/dialog_delete_playlist.xml new file mode 100644 index 000000000..4987c3290 --- /dev/null +++ b/app/src/main/res/layout/dialog_delete_playlist.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_music_picker.xml b/app/src/main/res/layout/dialog_music_choices.xml similarity index 92% rename from app/src/main/res/layout/dialog_music_picker.xml rename to app/src/main/res/layout/dialog_music_choices.xml index f7f81f9af..64c36ada8 100644 --- a/app/src/main/res/layout/dialog_music_picker.xml +++ b/app/src/main/res/layout/dialog_music_choices.xml @@ -2,7 +2,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_song_detail.xml b/app/src/main/res/layout/dialog_song_detail.xml index 1f31d8ce2..9fd015e38 100644 --- a/app/src/main/res/layout/dialog_song_detail.xml +++ b/app/src/main/res/layout/dialog_song_detail.xml @@ -1,6 +1,5 @@ - - + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 5e8da8d81..712509a65 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -12,20 +12,29 @@ android:id="@+id/home_appbar" style="@style/Widget.Auxio.AppBarLayout"> - - + + + - + android:layout_margin="@dimen/spacing_medium" /> diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 6c22299d1..abc7469ee 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -8,8 +8,6 @@ android:background="?attr/colorSurface" android:transitionGroup="true"> - - + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" + tools:targetApi="n" /> diff --git a/app/src/main/res/layout/fragment_queue.xml b/app/src/main/res/layout/fragment_queue.xml index 2fb2319b9..f53cc6d85 100644 --- a/app/src/main/res/layout/fragment_queue.xml +++ b/app/src/main/res/layout/fragment_queue.xml @@ -10,7 +10,7 @@ style="@style/Widget.Auxio.RecyclerView.Linear" android:layout_width="match_parent" android:layout_height="match_parent" - tools:listitem="@layout/item_queue_song" /> + tools:listitem="@layout/item_editable_song" /> - - + + + diff --git a/app/src/main/res/layout/item_album_song.xml b/app/src/main/res/layout/item_album_song.xml index 7a0e7ece7..2505dcf32 100644 --- a/app/src/main/res/layout/item_album_song.xml +++ b/app/src/main/res/layout/item_album_song.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/ui_item_ripple" + android:background="@drawable/ui_item_bg" android:paddingStart="@dimen/spacing_medium" android:paddingTop="@dimen/spacing_mid_medium" android:paddingEnd="@dimen/spacing_mid_medium" diff --git a/app/src/main/res/layout/item_edit_header.xml b/app/src/main/res/layout/item_edit_header.xml new file mode 100644 index 000000000..80659deca --- /dev/null +++ b/app/src/main/res/layout/item_edit_header.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_queue_song.xml b/app/src/main/res/layout/item_editable_song.xml similarity index 81% rename from app/src/main/res/layout/item_queue_song.xml rename to app/src/main/res/layout/item_editable_song.xml index 60cdba6bb..93fe6f0de 100644 --- a/app/src/main/res/layout/item_queue_song.xml +++ b/app/src/main/res/layout/item_editable_song.xml @@ -18,7 +18,7 @@ android:layout_height="wrap_content" android:layout_gravity="end|center_vertical" android:layout_marginEnd="@dimen/spacing_small" - android:contentDescription="@string/desc_clear_queue_item" + android:contentDescription="@string/desc_remove_song" android:padding="@dimen/spacing_medium" android:src="@drawable/ic_delete_24" app:tint="?attr/colorOnError" /> @@ -32,7 +32,7 @@ android:id="@+id/interact_body" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?attr/selectableItemBackground"> + android:background="@drawable/ui_item_ripple"> + + diff --git a/app/src/main/res/layout/item_new_playlist_choice.xml b/app/src/main/res/layout/item_new_playlist_choice.xml new file mode 100644 index 000000000..471c9c739 --- /dev/null +++ b/app/src/main/res/layout/item_new_playlist_choice.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_parent.xml b/app/src/main/res/layout/item_parent.xml index f868d01aa..7b9a316db 100644 --- a/app/src/main/res/layout/item_parent.xml +++ b/app/src/main/res/layout/item_parent.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/ui_item_ripple" + android:background="@drawable/ui_item_bg" android:paddingStart="@dimen/spacing_medium" android:paddingTop="@dimen/spacing_mid_medium" android:paddingEnd="@dimen/spacing_mid_medium" diff --git a/app/src/main/res/layout/item_song.xml b/app/src/main/res/layout/item_song.xml index 570aab4cc..9f6d403f7 100644 --- a/app/src/main/res/layout/item_song.xml +++ b/app/src/main/res/layout/item_song.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/ui_item_ripple" + android:background="@drawable/ui_item_bg" android:paddingStart="@dimen/spacing_medium" android:paddingTop="@dimen/spacing_mid_medium" android:paddingEnd="@dimen/spacing_mid_medium" diff --git a/app/src/main/res/layout/item_sort_header.xml b/app/src/main/res/layout/item_sort_header.xml index 7f2deab47..ef24e6d6b 100644 --- a/app/src/main/res/layout/item_sort_header.xml +++ b/app/src/main/res/layout/item_sort_header.xml @@ -1,5 +1,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_album_detail.xml b/app/src/main/res/menu/menu_album_detail.xml index 4c5d8d7d5..34de6eb5e 100644 --- a/app/src/main/res/menu/menu_album_detail.xml +++ b/app/src/main/res/menu/menu_album_detail.xml @@ -9,4 +9,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_album_song_actions.xml b/app/src/main/res/menu/menu_album_song_actions.xml index 9bf6ba0a6..256322f3e 100644 --- a/app/src/main/res/menu/menu_album_song_actions.xml +++ b/app/src/main/res/menu/menu_album_song_actions.xml @@ -9,6 +9,9 @@ + diff --git a/app/src/main/res/menu/menu_artist_album_actions.xml b/app/src/main/res/menu/menu_artist_album_actions.xml index 5078496da..c94d6886f 100644 --- a/app/src/main/res/menu/menu_artist_album_actions.xml +++ b/app/src/main/res/menu/menu_artist_album_actions.xml @@ -15,4 +15,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_artist_song_actions.xml b/app/src/main/res/menu/menu_artist_song_actions.xml index 14987e8f5..4b20abd21 100644 --- a/app/src/main/res/menu/menu_artist_song_actions.xml +++ b/app/src/main/res/menu/menu_artist_song_actions.xml @@ -9,6 +9,9 @@ + diff --git a/app/src/main/res/menu/menu_edit_actions.xml b/app/src/main/res/menu/menu_edit_actions.xml new file mode 100644 index 000000000..10ac3d9ef --- /dev/null +++ b/app/src/main/res/menu/menu_edit_actions.xml @@ -0,0 +1,9 @@ + +

+ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_artist_actions.xml b/app/src/main/res/menu/menu_parent_actions.xml similarity index 82% rename from app/src/main/res/menu/menu_artist_actions.xml rename to app/src/main/res/menu/menu_parent_actions.xml index df535f77d..4e6112035 100644 --- a/app/src/main/res/menu/menu_artist_actions.xml +++ b/app/src/main/res/menu/menu_parent_actions.xml @@ -12,4 +12,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_genre_artist_detail.xml b/app/src/main/res/menu/menu_parent_detail.xml similarity index 74% rename from app/src/main/res/menu/menu_genre_artist_detail.xml rename to app/src/main/res/menu/menu_parent_detail.xml index e480d9191..3a2225ea3 100644 --- a/app/src/main/res/menu/menu_genre_artist_detail.xml +++ b/app/src/main/res/menu/menu_parent_detail.xml @@ -6,4 +6,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playback.xml b/app/src/main/res/menu/menu_playback.xml index fd9e761a0..0c24bb5da 100644 --- a/app/src/main/res/menu/menu_playback.xml +++ b/app/src/main/res/menu/menu_playback.xml @@ -14,6 +14,9 @@ android:id="@+id/action_go_album" android:title="@string/lbl_go_album" app:showAsAction="never" /> + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_detail.xml b/app/src/main/res/menu/menu_playlist_detail.xml new file mode 100644 index 000000000..05a11b388 --- /dev/null +++ b/app/src/main/res/menu/menu_playlist_detail.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_song_actions.xml b/app/src/main/res/menu/menu_playlist_song_actions.xml new file mode 100644 index 000000000..28c508681 --- /dev/null +++ b/app/src/main/res/menu/menu_playlist_song_actions.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_search.xml b/app/src/main/res/menu/menu_search.xml index 09ee14a7c..0e82cb0f1 100644 --- a/app/src/main/res/menu/menu_search.xml +++ b/app/src/main/res/menu/menu_search.xml @@ -28,6 +28,10 @@ android:id="@+id/option_filter_genres" android:title="@string/lbl_genres" app:showAsAction="never" /> + diff --git a/app/src/main/res/menu/menu_selection_actions.xml b/app/src/main/res/menu/menu_selection_actions.xml index ad023050c..568d04a62 100644 --- a/app/src/main/res/menu/menu_selection_actions.xml +++ b/app/src/main/res/menu/menu_selection_actions.xml @@ -10,6 +10,9 @@ android:id="@+id/action_selection_queue_add" android:title="@string/lbl_queue_add" app:showAsAction="never" /> + + diff --git a/app/src/main/res/navigation/nav_explore.xml b/app/src/main/res/navigation/nav_explore.xml index a7ecabf0f..dfce49cb9 100644 --- a/app/src/main/res/navigation/nav_explore.xml +++ b/app/src/main/res/navigation/nav_explore.xml @@ -49,11 +49,29 @@ android:id="@+id/action_show_album" app:destination="@id/album_detail_fragment" /> + + + + + + @@ -72,6 +90,9 @@ + diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 42ea52268..54a1a4f37 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -18,56 +18,110 @@ android:id="@+id/action_show_details" app:destination="@id/song_detail_dialog" /> + android:id="@+id/action_new_playlist" + app:destination="@id/new_playlist_dialog" /> + + + + app:destination="@id/navigate_to_artist_dialog" /> + + app:destination="@id/play_from_genre_dialog" /> - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + إعادة المحاولة منح - انواع + الانواع فنانين البومات اغاني - جميع الاغاني + كل الاغاني بحث تصفية الكل @@ -18,27 +18,27 @@ اسم فنان البوم - سنة + التاريخ تصاعدي - يعمل الآن + يتم التشغيل الان تشغيل - خلط + عشوائي تشغيل من جميع الاغاني تشغيل من البوم تشغيل من فنان طابور - شغل تالياً + شغل الاغنية التالية أضف إلى الطابور تمت الإضافة إلى الطابور أذهب إلى الفنان أذهب إلى الالبوم تم حفظ الحالة أضف - احفظ + حفظ لا مجلد حول الإصدار - عرض على جيتهاب + عرض على الكود في Github التراخيص تمت برمجة التطبيق من قبل OxygenCobalt @@ -89,8 +89,8 @@ تغيير وضع التكرار تشغيل او اطفاء الخلط خلط جميع الاغاني - إزالة اغنية من الطابور - نقل اغنية من الطابور + إزالة اغنية من الطابور + نقل اغنية من الطابور تحريك التبويت إزالة كلمة البحث إزالة المجلد المستبعد @@ -139,4 +139,58 @@ %d ألبومات %d ألبومات + MixMix + الغاء + التنسيق + الحجم + المسار + إحصائيات المكتبة + تشغي الاغاني المحددة بترتيب عشوائي + تشغيل الموسيقى المحددة + معدل البت + اسم الملف + تجميع مباشر + تجميعات + خصائص الاغنية + معدل العينة + عشوائي + تشغيل كل الاغاني بشكل عشوائي + حسنا + اعادة الحالة + تنازلي + عرض الخصائص + مسح الحالة + مباشر + اعادة ضبط + يتم تحمل مكتبتك … + النوع + مراقبة تغييرات في مكتبتك + مراقبة مكتبة الموسيقة + تحميل الموسيقى + المعادل + منفصل + فردي + EP + EPs + Mixtape + تسجيل صوتي + Mixtapes + RemixesRemixes + الموسيقى التصويرية + البوم مباشر + ريمكس + مؤاثرات مباشرة + مؤاثرات ريمكس + بث مباشر فردي + ريمكس منفصل + تجميعات + مدة + عدد الأغاني + قرص + مسار + تاريخ الاضافة + تحميل الموسيقى + التحويل البرمجي + مزيج + Wiki \ No newline at end of file diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index bc92727aa..833d8340e 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -44,7 +44,7 @@ Светлая Жывы сінгл Рэмікс сінгла - Зборнікі рэміксаў + Зборнік рэміксаў Саўндтрэкі Мікстэйпы Мікстэйп @@ -144,7 +144,7 @@ Гэтая папка не падтрымліваецца Немагчыма аднавіць стан Кампазіцыя %d - Перамясціць песню ў чаргу + Перамясціць песню ў чаргу Не знойдзена прыкладання, якое можа справіцца з гэтай задачай Прайграванне або прыпыненне Немагчыма захаваць стан @@ -153,7 +153,7 @@ Змяніць рэжым паўтору Значок Auxio Уключыце або выключыце перамешванне - Выдаліць гэтую песню з чаргі + Выдаліць гэтую песню з чаргі Перамяшаць усе песні Спыніць прайграванне Адкрыйце чаргу @@ -271,4 +271,14 @@ Ігнараваць артыклі пры сартаванні Міні-альбомы Міні-альбом + Вокладка плэйліст для %s + Плэйліст + Плэйлісты + Стварыце новы плэйліст + Плэйліст %d + Новы плэйліст + Дадаць у плэйліст + Плэйліст створаны + Паведамленні ў плэйліст + Без трэкаў \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index fe32967f3..900a8e440 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -109,8 +109,8 @@ Změnit režim opakování Vypnout nebo zapnout náhodné přehrávání Náhodně přehrávat vše - Odebrat tuto skladbu z fronty - Přesunout tuto skladbu ve frontě + Odebrat tuto skladbu z fronty + Přesunout tuto skladbu ve frontě Přesunout tuto kartu Vymazat hledání Odebrat složku @@ -191,7 +191,7 @@ Velikost Přenosová rychlost Vzorkovací frekvence - Hudební složky + Složky s hudbou Obnovit stav přehrávání Stav obnoven Obnovit dříve uložený stav přehrávání (pokud existuje) @@ -237,7 +237,7 @@ Čárka (,) Středník (;) Lomítko (/) - Remixové kompilace + Remixová kompilace Mixy Živá kompilace Mix @@ -280,6 +280,23 @@ Knihovna Perzistence Sestupně + Seznamy skladeb + Obrázek seznamu skladeb pro %s + Seznam skladeb Při řazení ignorovat předložky Ignorovat slova jako „the“ při řazení podle názvu (funguje nejlépe u hudby v angličtině) + Vytvořit nový playlist + Přidat do seznamu skladeb + Přidáno do seznamu skladeb + Seznam skladeb vytvořen + Žádné skladby + Nový seznam skladeb + Seznam skladeb %d + Odstranit + Odstranit seznam skladeb\? + Odstranit seznam %s\? Tato akce je nevratná. + Přejmenovat + Seznam skladeb přejmenován + Seznam skladeb odstraněn + Přejmenovat seznam skladeb \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index adf3ad6d4..72c42e4a7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,7 +19,7 @@ Von Album abspielen Aktuelle Wiedergabe Warteschlange - Spiele als Nächstes + Als Nächstes abspielen Zur Warteschlange hinzufügen Der Warteschlange hinzugefügt Zum Künstler gehen @@ -125,7 +125,7 @@ Pause bei Wiederholung Pausieren, wenn ein Song wiederholt wird Zufällig an- oder ausschalten - Lied in der Warteschlange verschieben + Lied in der Warteschlange verschieben Verzechnis entfernen Albumcover Keine Musik wird gespielt @@ -133,18 +133,18 @@ Sichtbarkeit und Ordnung der Bibliotheksregisterkarten ändern Name Alle Lieder zufällig - Lied in der Warteschlange löschen + Lied in der Warteschlange löschen Tab versetzen Unbekannter Künstler Dauer Anzahl der Lieder - Schallplatte + Disc Titel OK Bibliotheksstatistiken Album bevorzugen, wenn eines abgespielt wird Dynamische Farbe - Schallplatte %d + Disc %d +%.1f dB -%.1f dB Geladene Alben: %d @@ -156,12 +156,12 @@ Wenn ein Lied aus den Elementdetails abgespielt wird Vom dargestellten Element abspielen Musikordner - Verwalte, von wo die Musik geladen werden sollte + Verwalten, von wo die Musik geladen werden soll Modus Ausschließen - Musik wird nicht von den von dir hinzugefügten Ordnern geladen. + Musik wird nicht aus den von dir hinzugefügten Ordnern geladen. Einschließen - Musik wird nur von den von dir hinzugefügten Ordnern geladen. + Musik wird nur aus den von dir hinzugefügten Ordnern geladen. Kein Titel Ogg-Audio MPEG-4-Audio @@ -175,9 +175,9 @@ Mischen Alle mischen Musikwiedergabe - Lade deine Musikbibliothek… + Deine Musikbibliothek wird geladen… Abtastrate - Zeige Eigenschaften an + Eigenschaften ansehen Lied-Eigenschaften Dateiname Elternpfad @@ -186,7 +186,7 @@ Bitrate Überwachen der Musikbibliothek Musik wird geladen - Musikbibliothek wird auf Änderungen überwacht… + Änderungen in deiner Musikbibliothek werden überprüft… Hinzugefügt am Musikbibliothek neu laden, sobald es Änderungen gibt (erfordert persistente Benachrichtigung) Automatisch neuladen @@ -194,7 +194,7 @@ Den vorher gespeicherten Wiedergabezustand wiederherstellen (wenn verfügbar) Zustand konnte nicht wiederhergestellt werden EP - EPs + Mini-Alben Single Singles Kompilationen @@ -250,7 +250,7 @@ Zustand konnte nicht gelöscht werden Zustand konnte nicht gespeichert werden Music neu scannen - Tag-Cache vollständig löschen und die Musik-Bibliothek vollständig neu laden (langsamer, aber vollständiger) + Tag-Cache leeren und die Musik-Bibliothek vollständig neu laden (langsamer, aber vollständiger) Ausgewählte abspielen Ausgewählte zufällig abspielen %d ausgewählt @@ -259,7 +259,7 @@ %1$s, %2$s Zurücksetzen Verhalten - Ändern Sie das Thema und die Farben der App + Thema und Farben der App ändern UI-Steuerelemente und Verhalten anpassen Steuern, wie Musik und Bilder geladen werden Musik @@ -271,6 +271,23 @@ Persistenz Lautstärkeanpassung ReplayGain Absteigend + Wiedergabelistenbild für %s + Wiedergabeliste + Wiedergabelisten Artikel beim Sortieren ignorieren Wörter wie „the“ ignorieren (funktioniert am besten mit englischsprachiger Musik) + Neue Wiedergabeliste erstellen + Neue Wiedergabeliste + Zur Wiedergabeliste hinzugefügt + Zur Wiedergabeliste hinzufügen + Wiedergabeliste erstellt + Löschen + Wiedergabeliste löschen\? + Keine Lieder + Wiedergabeliste %d + %s löschen\? Dies kann nicht rückgängig gemacht werden. + Umbenennen + Wiedergabeliste umbenennen + Wiedergabeliste umbenannt + Wiedergabeliste gelöscht \ No newline at end of file diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 8a0c55734..66749ed55 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -2,7 +2,7 @@ Προσπάθεια εκ νέου - Χορήγησε + Χορήγηση Είδη Καλλιτέχνες Άλμπουμ @@ -13,7 +13,7 @@ Όλα Σειρά Διάταξης Αναπαραγωγή - Tυχαία αναπαραγωγή + Τυχαία αναπαραγωγή Τώρα Παίζεται Ουρά αναπαραγωγής Επόμενο @@ -81,10 +81,10 @@ Πρόσθεση Ιδιότητες τραγουδιού Όνομα αρχείου - Προβολή Ιδιωτήτων + Προβολή Ιδιοτήτων Στατιστικά συλλογής - Ζωντανό Album - Ρεμίξ album + Ζωντανό άλμπουμ + Ρεμίξ άλμπουμ Ζωντανό EP Ρεμίξ EP Συλλογές @@ -97,10 +97,10 @@ Διάρκεια Συνολική διάρκεια: %s Καθόλου φάκελοι - Μιά απλή, λογική εφαρμογή αναπαραγωγής μουσικής για Android. + Μια απλή, λογική εφαρμογή αναπαραγωγής μουσικής για Android. Φόρτωση μουσικής Προβολή και έλεγχος αναπαραγωγής μουσικής - Album + Άλμπουμ EP EP Καλλιτέχνης @@ -132,4 +132,11 @@ Ζωντανά Φάκελοι μουσικής Μουσικο κομματι + Σύνθεση ζωντανών κομματιών + Σύνθεση ρεμίξ + Ισοσταθμιστής + Αναπαραγωγή επιλεγμένου + Τυχαία αναπαραγωγή επιλεγμένων + Ενιαία κυκλοφορία + Σινγκλ \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b51068c59..de9f77187 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -91,8 +91,8 @@ Cambiar modo de repetición Act/des mezcla Mezclar todo - Quitar canción de la cola - Mover canción en la cola + Quitar canción de la cola + Mover canción en la cola Mover pestaña Borrar historial de búsqueda Quitar carpeta @@ -275,6 +275,24 @@ Personalizar los controles y el comportamiento de la interfaz de usuario Biblioteca Descendente + Listas de reproducción + Imagen de la lista de reproducción para %s + Lista de reproducción Ignorar artículos al ordenar Ignorar palabras como \"the\" al ordenar por nombre (funciona mejor con música en inglés) + Crear una nueva lista de reproducción + Nueva lista de reproducción + Lista de reproducción %d + Agregar a la lista de reproducción + Agregado a la lista de reproducción + Lista de reproducción creada + No hay canciones + Borrar + Cambiar el nombre + Cambiar el nombre de la lista de reproducción + Lista de reproducción renombrada + Lista de reproducción borrada + ¿Borrar %s\? Esto no se puede deshacer. + ¿Borrar la lista de reproducción\? + Editar \ No newline at end of file diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml new file mode 100644 index 000000000..cd35522f9 --- /dev/null +++ b/app/src/main/res/values-fa/strings.xml @@ -0,0 +1,29 @@ + + + بارگیری موسیقی + یک پخش کننده موسیقی ساده و منطقی برای اندروید. + تلاش دوباره + اجازه دادن + آلبوم + آلبوم زنده + آلبوم ریمیکس + آماده‎سازی موسیقی + چک‎کردن کلکسیون موسیقی + ترانه‎ها + تمام ترانه‎ها + آلبوم‎ها + آلبوم‎های کوتاه + آلبوم کوتاه + آلبوم کوتاه زنده + آلبوم کوتاه ریمیکس + تک‎آهنگ + تک‎آهنگ‎ها + تک‎آهنگ زنده + موسیقی‎متن + ریمیکس زنده + مجموعه‎ها + مجموعه + مجموعه زنده + مجموعه ریمیکس‎ها + موسیقی‎‎های متن + \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml new file mode 100644 index 000000000..85ff6f8e5 --- /dev/null +++ b/app/src/main/res/values-fi/strings.xml @@ -0,0 +1,246 @@ + + + Yksinkertainen ja rationaalinen musiikkisoitin Androidille. + Musiikki latautuu + Ladataan musiikkia + Yritä uudelleen + Anna lupa + Kappaleet + Kaikki kappaleet + Albumit + Albumi + EP:t + EP + Live-EP + Singlet + Live-single + Kokoelmat + Kokoelma + Live-kokoelma + Remixit + Esittäjä + Tyylilaji + Tyylilajit + Soittolista + Mix + Live + Soittolistat + Etsi + Suodata + Kaikki + Nimi + Kappalemäärä + Levy + Raita + Lisäyspäivä + Laskevasti + Nyt toistetaan + Taajuuskorjain + Toista + Toisto valittu + Sekoita + Jono + Lisää jonoon + Siirry albumiin + Näytä ominaisuudet + Kappaleen ominaisuudet + Tiedostonimi + Ylätason polku + Muoto + Koko + Bittitaajuus + Näytteeottotaajuus + OK + Peruuta + Tallenna + Palauta oletus + Lisää + Tila tallennettu + Tila tyhjennetty + Tila palautettu + Valvotaa musiikkikirjastoa muutosten varalta… + Sekoita + Sekoita kaikki + Ulkoasu + Näytä + Kirjaston välilehdet + Toiminta + Muuta kirjastovälilehtien näkyvyyttä ja järjestystä + Siirry seuraavaan + Kertaustila + Kirjastosta toistettaessa + Kohteen tiedoista toistettaessa + Muista sekoitus + Toista kaikista kappaleista + Toista albumilta + Toista tyylilajista + Moniarvoerottimet + Ohita äänitiedostot, jotka eivät ole musiikkia, kuten podcastit + Ja-merkki (&) + Pilkku (,) + Plus (+) + Puolipiste (;) + Älykäs järjestys + Piilota avustajat + Pois + Nopea + Korkea laatu + Ääni + Määritä äänen ja toiston toimintaa + Toisto + Suosi kappaletta + ReplayGainin esivahvistus + Kirjasto + Musiikkikansiot + Määritä mistä musiikki tulee ladata + Läpikäy musiikki uudelleen + Tila + Ohita + Sisällytä + Musiikkia ladataan vain lisäämistäsi kansioista. + Tallenna toiston tila + Pysyvyys + Tyhjennä toiston tila + Palauta toiston tila + Tyhjennä aiemmin tallennettu toiston tila (jos olemassa) + Tähän tehtävään kykenevää sovellusta ei löytynyt + Ei kansioita + Tilaa ei voi palauttaa + Tilaa ei voi tyhjentää + Raita %d + Siirry seuraavaan kappaleeseen + Muuta kertaustilaa + Luo uusi soittolista + Pysäytä toisto + Avaa jono + Poista kansio + Auxion kuvake + Albumin %s kansi + Tyylilajin %s kuva + Soittolistan %s kuva + Tuntematon esittäjä + Ei päiväystä + Ei raitaa + MPEG-1-ääni + MPEG-4-ääni + Ogg-ääni + Matroska-ääni + Advanced Audio Coding (AAC) + Free Lossless Audio Codec (FLAC) + Punainen + Vaaleanpunainen + Violetti + Keltainen + Ruskea + %1$s, %2$s + %d valittu + Indigo + Sininen + Syvä sininen + Syaani + Sinivihreä + Syvä vihreä + Limenvihreä + Dynaaminen + -%.1f dB + %d kbps + Ladataan musiikkikirjastoa… (%1$d/%2$d) + Kappaleita ladattu: %d + Albumeita ladattu: %d + Tyylilajeja ladattu: %d + + %d kappale + %d kappaletta + + + %d albumi + %d albumia + + Tarkkaillaan musiikkikirjastoa + Lähdekoodi + Kehittänyt Alexander Capehart + Kesto yhteensä: %s + Live-albumi + Remix-albumi + Remix-EP + Remix-single + Remix-kokoelmat + Ladataan musiikkikirjastoa… + Versio + Väriteema + Musta teema + Mukauta + Kesto + Single + Elokuvamusiikki + Esittäjät + Etsi kirjastosta… + Lisenssit + Tietoja + Kirjaston tilastot + Lisätty jonoon + Käytä mustaa teemaa + Pyöristetty tila + Elokuvamusiikit + Mixaukset + Auxio tarvitsee luvan lukea musiikkikirjastoa + Asetukset + Järjestä + Musiikkia ei löytynyt + Wiki + Harmaa + Muuta sovellukse teemaa ja värejä + Teema + Automaattinen + Vaalea + Tumma + Mukauta käyttöliittymän säätimiä ja toimintaa + Oranssi + Levy %d + %d Hz + Päiväys + Määritä miten musiikki ja kuvat ladataan + Päivitä musiikki + Musiikin lataaminen epäonnistui + Albumikansi + +%.1f dB + Esittäjiä ladattu: %d + + %d esittäjä + %d esittäjää + + Nousevasti + Toista seuraava + Siirry esittäjään + Sisältö + Musiikki + Kuvat + Albumikannet + ReplayGain + Suosi albumia + ReplayGain-strategia + Sekoitus valittu + Automaattinen uudelleenlataus + Automaattitoisto kuulokkeilla + Aloita aina toisto, kun kuulokkeet yhdistetään (ei välttämättä toimi kaikilla laitteilla) + Tallenna nykyinen toiston tila + Siirry viimeiseen kappaleeseen + Kansiot + Toista tai keskeytä + Tämä kansio ei ole tuettu + Sekoitus päällä/pois + Sekoita kaikki kappaleet + Tilaa ei voi tallentaa + Siirry tätä välilehteä + Tyhjennä hakuehto + Esittäjän %s kuva + Syvä violetti + Tuntematon tyylilaji + Vihreä + Musiikkia ei toisteta + Toista esittäjältä + Ohita muu kuin musiikki + Palauta aiemmin tallennettu toiston tila (jos olemassa) + Musiikkia ei ladata valitsemistasi kansioista. + Suosi albumia, jos sellaista toistetaan + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e6737ab82..353726d0a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -147,10 +147,10 @@ Réinitialiser Ogg audio Violet Claire - MPEG-1 audio + Audio MPEG-1 Échec du chargement de la musique Wiki - MPEG-4 audio + Audio MPEG-4 Pas de date Couverture de l\'album pour %s État effacé @@ -165,4 +165,75 @@ Comportement Action personnalisée de la barre de lecture Changer le thème et les couleurs de l\'application + Contenu + Recharger la bibliothèque musicale chaque fois qu\'elle change (nécessite une notification persistante) + Esperluette (&) + Playlist + Lors de la lecture à partir des détails de l\'élément + Gardez la lecture aléatoire lors de la lecture d\'une nouvelle chanson + Lire à partir de l\'élément affiché + N\'oubliez pas de mélanger + Contrôlez le chargement de la musique et des images + Musique + Images + Qualité améliorée (chargement lent) + Configurer les caractères qui indiquent plusieurs valeurs de balise + Pochettes d\'albums + Masquer les collaborateurs + Afficher uniquement les artistes qui sont directement crédités sur un album (fonctionne mieux sur les bibliothèques bien étiquetées) + Désactivé + Couvertures originales (téléchargement rapide) + Configurer le son et le comportement de lecture + Listes de lecture + Lors de la lecture depuis la bibliothèque + Séparateurs multi-valeurs + Rechargement automatique + Jouer à partir de toutes les chansons + Jouer de l\'artiste + Jouer à partir du genre + Virgule (,) + Point-virgule (;) + Ignorer les fichiers audio qui ne sont pas de la musique, tels que les podcasts + Avertissement: L\'utilisation de ce paramètre peut entraîner l\'interprétation incorrecte de certaines balises comme ayant plusieurs valeurs. Vous pouvez résoudre ce problème en préfixant les caractères de séparation indésirables avec une barre oblique inverse (\\). + Exclure non-musique + Lire depuis l\'album + Barre oblique (/) + Plus (+) + Vider l\'état de lecture précédemment enregistré (si il existe) + Ajustement avec étiquettes + Dossiers de musique + Gérer d\'où la musique doit être chargée + Lecture + Persistance + Vider l\'état de lecture + Toujours commencer la lecture lorsqu\'un périphérique audio est connecté (pourrait ne pas fonctionner sur tous les appareils) + Stratégie de normalisation de volume + Par chanson + Par album + Dossiers + Par album si un album est en lecture + Bibliothèque + La musique sera uniquement chargée des dossiers ajoutés. + Inclure + Actualiser la musique + Effacer le cache des étiquettes et recharger entièrement la bibliothèque musicale (lent, mais plus complet) + Aucune application trouvée qui puisse gérer cette tâche + Impossible de restaurer l\'état + Rétablir l\'état de lecture + Auxio a besoin de permissions pour lire votre bibliothèque musicale + Tri intelligent + Ignorer les nombres ou certains mots comme \"the\" en début de nom lors du tri (fonctionne au mieux avec de la musique en anglais) + Les dossiers de musique ajoutés ne seront pas chargés. + Scanner à nouveau la musique + Ajustement sans étiquettes + Enregistrer l\'état de lecture actuel maintenant + Rétablir l\'état de lecture enregistré précédemment (si il existe) + Volume normalisé + Le préampli est appliqué à l\'ajustement actuel durant la lecture + Enregistrer l\'état de lecture + Lecture automatique avec casque audio + Normalisation de volume par préampli + Recharger la bibliothèque musicale en utilisant si possible les étiquettes en cache + Mode + Exclure \ No newline at end of file diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 89c5485b8..022bca385 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -119,7 +119,7 @@ Reproducir ou pausar Saltar á seguinte canción Monitorizando a biblioteca de música - EPs + Reproducións ampliadas (EPs) EP EP en directo EP remix @@ -203,8 +203,8 @@ Advanced Audio Coding (AAC) Free Lossless Audio Codec (FLAC) Vermello - Quitar esta canción da cola - Mover está canción na cola + Quitar esta canción da cola + Mover está canción na cola Mover esta pestana Rosa Morado @@ -255,4 +255,17 @@ Sen música Pista %d Audio Matroska + Mixtapes (compilación de cancións) + Mixtapes (compilación de cancións) + Remix + Barra (/) + Aviso: o uso desta configuración pode provocar que algunhas etiquetas interpretense incorrectamente como que teñen varios valores. Podes resolver isto antepoñendo caracteres separadores non desexados cunha barra invertida (\\). + Estratexia da ganancia da repetición + Preamplificador ReplayGain + Listas de reprodución + lista de reprodución + O preamplificador aplícase ao axuste existente durante a reprodución + Imaxe da lista de reprodución para %s + ampersand + ganancia da repetición \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 9949d2df9..157e025f0 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -35,7 +35,7 @@ चलाएं/रोकें - संग्रह में खोजें + संग्रह में खोजें… @@ -56,4 +56,7 @@ ठीक है कलाकार तिथि जोड़ी गई + गाने लोड हो रहे है + गाने लोड हो रहे है + एंड्रॉयड के लिए एक सीधा साधा, विवेकशील गाने बजाने वाला ऐप। \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 5b810c7e6..783f6f805 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -107,8 +107,8 @@ Zvučni zapis %d Omogućite ili onemogućite miješanje Izmiješaj sve pjesme - Ukoni ovu pjesmu iz popisa pjesama - Premjesti ovu pjesmu u popisu pjesama + Ukoni ovu pjesmu iz popisa pjesama + Premjesti ovu pjesmu u popisu pjesama Pomakni ovu pločicu Izbriši pretražene pojmove Ukloni mapu @@ -119,7 +119,7 @@ Slika žanra za %s Nepoznat izvođač Nepoznat žanr - Bez broja pjesama + Nema staze Bez datuma Glazba se ne reproducira MPEG-1 zvuk @@ -214,7 +214,7 @@ Zarez (,) Ampersand (&) Kompilacija uživo - Kompilacije remiksa + Kompilacija remiksa Kompilacije Znakovi odjeljivanja vrijednosti Prekini reprodukciju @@ -253,4 +253,37 @@ Wiki %1$s, %2$s Resetiraj + ReplayGain izjednačavanje glasnoće + Mape + Silazni + Promijenite temu i boje aplikacije + Prilagodite kontrole i ponašanje korisničkog sučelja + Upravljajte učitavanjem glazbe i slika + Slike + Konfigurirajte ponašanje zvuka i reprodukcije + Reprodukcija + Fonoteka + Status reprodukcije + Popisi pjesama + Popis pjesama + Glazba + Slika popisa pjesama za %s + Ponašanje + Pametno razvrstavanje + Ispravno razvrstaj imena koja počinju brojevima ili riječima poput „the” (najbolje radi s glazbom na engleskom jeziku) + Stvori novi popis pjesama + Novi popis pjesama + Dodaj u popis pjesama + Nema pjesama + Izbriši + Popis pjesama %d + Preimenuj + Preimenuj popis pjesama + Izbrisati popis pjesama\? + Popis pjesama je stvoren + Popis pjesama je preimenovan + Popis pjesama je izbrisan + Dodano u popis pjesama + Uredi + Izbrisati %s\? To je nepovratna radnja. \ No newline at end of file diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 54f651893..4e9be7cdf 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -139,7 +139,7 @@ Gambar Artis untuk %s Saat diputar dari keterangan item Musik tidak akan dimuat dari folder yang Anda tambahkan. - Hapus lagu antrian ini + Hapus lagu antrian ini Hapus kueri pencarian Penyesuaian tanpa tag Folder musik @@ -159,7 +159,7 @@ Ikon Auxio Sampul album Aktifkan atau nonaktifkan acak - Pindahkan lagu antrian ini + Pindahkan lagu antrian ini Tidak ada musik yang diputar Audio Ogg Cokelat diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 665e6a720..19d059cd1 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -94,8 +94,8 @@ Cambia modalità ripetizione Attiva o disattiva mescolamento Mescola tutte le canzoni - Rimuove questa canzone della coda - Muove questa canzone della coda + Rimuove questa canzone della coda + Muove questa canzone della coda Muove questa scheda Cancella la query di ricerca Rimuovi cartella @@ -239,7 +239,7 @@ Attenzione: potrebbero verificarsi degli errori nella interpretazione di alcuni tag con valori multipli. Puoi risolvere aggiungendo come prefisso la barra rovesciata (\\) ai separatori indesiderati. E commerciale (&) Raccolte live - Raccolte remix + Raccolta di remix Mixes Mix Alta qualità @@ -275,6 +275,16 @@ Personalizza controlli e comportamento dell\'UI Configura comportamento di suono e riproduzione Discendente + Playlist + Playlist Ignora gli articoli durante l\'ordinamento Ignora parole come \"the\" durante l\'ordinamento per nome (funziona meglio con la musica in lingua inglese) + Crea una nuova playlist + Immagine della playlist per %s + Nuova playlist + Aggiungi a playlist + Playlist creata + Aggiunto alla playlist + Niente canzoni + Playlist %d \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1b8dfa355..62b5683b4 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -8,7 +8,7 @@ 曲の長さ 現在の再生状態を保存 このタブを移動 - この再生待ちの曲を移動 + この再生待ちの曲を移動 日付けがありません すべての曲 @@ -43,7 +43,7 @@ 前の曲にスキップ前に曲を巻き戻す 音楽フォルダ プラス (+) - リミックスオムニバス + リミックスコンピレーション DJミックス DJミックス ディスク @@ -91,7 +91,7 @@ 再生状態を復元できません トラック %d 再生またはポーズ - 再生待ちの曲を除去 + 再生待ちの曲を除去 フォルダを除去 Auxio アイコン アルバムカバー @@ -262,4 +262,14 @@ ReplayGain プリアンプ %1$s、%2$s UI コントロールと動作をカスタマイズする + プレイリスト + プレイリスト + %s のプレイリスト イメージ + 新規プレイリスト + プレイリストに追加する + プレイリストが作成されました + プレイリストに追加されました + 曲がありません + プレイリスト %d + 新しいプレイリストを作成する \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index eeab53168..c99c87e16 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -107,8 +107,8 @@ 반복 방식 변경 무작위 재생 켜기 또는 끄기 모든 곡 무작위 재생 - 이 대기열의 곡 제거 - 이 대기열의 곡 이동 + 이 대기열의 곡 제거 + 이 대기열의 곡 이동 이 탭 이동 검색 기록 삭제 폴더 제거 @@ -174,7 +174,7 @@ %d Hz 믹스 라이브 컴필레이션 - 리믹스 컴필레이션 + 리믹스 편집 믹스 이퀄라이저 셔플 @@ -271,6 +271,16 @@ 동작 UI 제어 및 동작 커스텀 내림차순 + 재생목록 + 재생목록 + %s의 재생 목록 이미지 정렬할 때 기사 무시 이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함) + 새 재생 목록 만들기 + 새 재생목록 + 재생목록에 추가 + 생성된 재생목록 + 재생목록에 추가됨 + 재생목록 %d + 노래 없음 \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 6bf884ce2..9cc1f6ec5 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -138,7 +138,7 @@ Pageidaujamas albumui, jei vienas groja Jokių programų nerasta, kurios galėtų atlikti šią užduotį „Auxio“ piktograma - Perkelti šią eilės dainą + Perkelti šią eilės dainą Perkelti šį skirtuką Muzikos krovimas nepavyko „Auxio“ reikia leidimo skaityti jūsų muzikos biblioteką @@ -173,7 +173,7 @@ Išvalyti paieškos užklausą Muzika nebus įkeliama iš pridėtų aplankų jūs pridėsite. Įtraukti - Pašalinti šią eilės dainą + Pašalinti šią eilės dainą Groti iš visų dainų Groti iš parodyto elemento Groti iš albumo @@ -271,4 +271,8 @@ Mažėjantis Ignoruoti tokius žodžius kaip „the“, kai rūšiuojama pagal pavadinimą (geriausiai veikia su anglų kalbos muzika) Ignoruoti straipsnius rūšiuojant + Grojaraštis + Grojaraščiai + Grojaraščio vaizdas %s + Sukurti naują grojaraštį \ No newline at end of file diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 1ddf8d3c8..fd2f7d19c 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -65,8 +65,8 @@ സംഗീതം കളിക്കുന്നില്ല മഞ്ഞ %d തിരഞ്ഞെടുത്തു - വരിയിലെ ഈ ഗാനം നീക്കം ചെയ്യുക - വരിയിലെ ഈ ഗാനം നീക്കുക + വരിയിലെ ഈ ഗാനം നീക്കം ചെയ്യുക + വരിയിലെ ഈ ഗാനം നീക്കുക പുനഃസജ്ജമാക്കുക തവിട്ട് %1$s, %2$s @@ -87,4 +87,13 @@ %d പാട്ട് %d പാട്ടുകൾ + കലാകാരനിലേക്ക് പോകുക + സവിശേഷതകൾ കാണുക + സ്ഥിതി സംരക്ഷിച്ചു + അവരോഹണം + സ്ഥിതി പുനഃസ്ഥാപിച്ചു + വിക്കി + സ്ഥിതി മായ്ച്ചു + തത്സമയം + തത്സമയ സമാഹാരം \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index bcbe1c649..263a5a955 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -162,8 +162,8 @@ Afspeelstatus herstellen Herstel de eerder opgeslagen afspeelstatus (indien aanwezig) Geen staat kan hersteld worden - Verwijder dit wachtrij liedje - Verplaats dit wachtrij liedje + Verwijder dit wachtrij liedje + Verplaats dit wachtrij liedje Verplaats deze tab Album cover Geen tracknummer diff --git a/app/src/main/res/values-or/strings.xml b/app/src/main/res/values-or/strings.xml new file mode 100644 index 000000000..9b90d0cd7 --- /dev/null +++ b/app/src/main/res/values-or/strings.xml @@ -0,0 +1,8 @@ + + + ହଳଦିଆ + କମଳା + ସବୁଜ + ନୀଳ + ଘନ ନୀଳ + \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 44c4a5d8c..7e70b3cda 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -196,8 +196,8 @@ ਲਾਇਬ੍ਰੇਰੀ ਸੰਗੀਤ ਫੋਲਡਰ ਕਤਾਰ ਖੋਲ੍ਹੋ - ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਹਟਾਓ - ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਮੂਵ ਕਰੋ + ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਹਟਾਓ + ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਮੂਵ ਕਰੋ ਦੁਹਰਾਓ ਮੋਡ ਬਦਲੋ ਸ਼ਫਲ ਚਾਲੂ ਜਾਂ ਬੰਦ ਕਰੋ ਸਾਰੇ ਗੀਤਾਂ ਨੂੰ ਸ਼ਫਲ ਕਰੋ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index dd48e1d43..c835f1de5 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2,7 +2,7 @@ Ponów - Przyznaj + Zezwól Gatunki Wykonawcy Albumy @@ -17,9 +17,9 @@ Losowo Obecnie odtwarzane Kolejka - Odtwarzaj następny + Odtwórz następny Dodaj do kolejki - Dodany do kolejki + Dodano do kolejki Przejdź do wykonawcy Przejdź do albumu O aplikacji @@ -28,28 +28,28 @@ Licencje Ustawienia - Rodzaj i zachowanie + Wygląd Motyw - Automatyczny + Systemowy Jasny Ciemny - Odcień koloru + Kolor akcentów Dźwięk - Zachowanie + Interfejs - Nie znaleziono muzyki + Nie znaleziono utworów - Ścieżka %d + Utwór %d Odtwórz bądź zapauzuj Szukaj w bibliotece… Czerwony Różowy - Fiolet + Fioletowy Indygo - Błękit - Ciemny błękit + Błękitny + Ciemny błękitny Zielony Ciemnozielony Żółtozielony @@ -59,10 +59,10 @@ Szary - %d Utwór - %d Utwory - %d Utworów - %d Utworów + %d utwór + %d utwory + %d utworów + %d utworów %d album @@ -81,7 +81,7 @@ Okładka albumu Nieznany gatunek Nieznana data - MPEG-1 audio + MPEG-1 Utwór Wyświetl szczegóły Szczegóły utworu @@ -102,7 +102,7 @@ Minialbum koncertowy Minialbum z remiksami Koncertowy singiel - Remix + Remiks Kompilacje Kompilacja Ścieżki dźwiękowe @@ -112,10 +112,10 @@ %d Hz Dodaj Czarny motyw - Ciemny fiolet + Ciemny fioletowy -%.1f dB - Nazwa - Rok + Tytuł + Data wydania Singiel Single Czas trwania @@ -125,55 +125,55 @@ Wykonawca Zapisz Składanki - Remixy + Remiksy Nieznany wykonawca Bitrate - Brak ścieżki dźwiękowej - Wyrównywacz + Brak utworu + Korektor Rozmiar Brak folderów Odtwórz wszystkie utwory Odtwórz album - Zacznij odtawrzanie po podłączeniu słuchawek (może nie działać na wszystkich urządzeniach) + Automatycznie odtwórz muzykę po podłączeniu słuchawek (może nie działać na wszystkich urządzeniach) Odśwież muzykę - Przeładuj bibliotekę muzyczną, używając buforowanych tagów, jeśli to możliwe - Usuń utwór z kolejki + Odśwież bibliotekę muzyczną używając tagów z pamięci cache, jeśli są dostępne + Usuń utwór z kolejki Preferuj album - Automatyczne ponowne załadowanie - Free Lossless Audio Codec (FLAC) - Ampersand (&) - Nie udało się załadować muzyki + Automatycznie odśwież + FLAC + Et (&) + Nie udało się zaimportować utworów Kompilacja remiksów Kompilacja koncertowa - Mixy - Mix + DJ Miksy + DJ Mix Przejdź do ostatniego utworu Przejdź do następnego utworu - Zaprojektowany Przez Alexandra Capeharta - Zaokrąglone okładki - Włącz zaokrąglone rogi na dodatkowych elementach interfejsu użytkownika (wymaga zaokrąglenia okładek albumów) + Autorstwa Alexandra Capeharta + Zaokrąglone krawędzie + Włącz zaokrąglone rogi na dodatkowych elementach interfejsu (wymaga zaokrąglenia okładek albumów) Akcja na pasku odtwarzania Następny utwór - Powtórz + Tryb powtarzania Ustawienie ReplayGain - Preferuj album, jeśli takowy jest odtwarzany - Odtwarzając utwór z widoku biblioteki + Preferuj album, jeśli jest odtwarzany + Odtwarzanie z widoku biblioteki Zapisz stan odtwarzania Przecinek (,) Średnik (;) Ukośnik (/) Plus (+) Zatrzymaj odtwarzanie - MPEG-4 audio - Ogg audio - Prosty i rozsądny odtwarzacz muzyki na Androida. + MPEG-4 + Ogg + Prosty i praktyczny odtwarzacz muzyki na Androida. Usuń folder Pokaż kolejkę - Advanced Audio Coding (AAC) - Automatycznie załaduj ponownie bibliotekę po wykryciu zmian (wymaga stałego powiadomienia) + AAC + Automatycznie odśwież bibliotekę po wykryciu zmian (wymaga stałego powiadomienia) Wyklucz - Uwzględnij - Zmień pozycję utworu w kolejce + Zawrzyj + Zmień pozycję utworu w kolejce Przesuń kartę Wizerunek wykonawcy dla %s Ładuję bibliotekę muzyczną… @@ -184,87 +184,87 @@ Karty w bibliotece Zmień widoczność i kolejność kart w bibliotece Użyj czarnego motywu - Wyświetlanie - Dynamiczny + Elementy + Material You %d kb/s Zapisz obecny stan odtwarzania Wyczyść stan odtwarzania - Matroska audio + Matroska Wyczyść poprzedni stan odtwarzania (jeśli istnieje) Przywróć stan odtwarzania Przywróć poprzedni stan odtwarzania (jeśli istnieje) Foldery z muzyką - Wybierz z których folderów aplikacja ma ładować utwory + Wybierz z których folderów importowane są utwory Tryb Przewiń przed odtworzeniem poprzedniego utworu - Przewiń do początku utworu przed odtworzeniem poprzedniego + Przewiń do początku obecnie odtwarzanego utworu zamiast odtworzenia poprzedniego Preamplifier ReplayGain Na żywo - Ładowanie utworów + Importowanie utworów Uwaga: Ustawienie wysokich pozytywnych wartości preamplifiera może skutkować przycinaniem dźwięku w niektórych utworach. Zapamiętaj losowe odtwarzanie - Nie znaleziono aplikacji mogącej wykonać to zadanie + Nie znaleziono odpowiedniej aplikacji Statystyki biblioteki - Załadowani artyści: %d + Zaimportowani artyści: %d Użyj alternatywnej akcji w powiadomieniu Zatrzymaj odtwarzanie przy powtórzeniu Wyczyść zapytanie wyszukiwania - Nie można przywrócić stanu + Nie można przywrócić stanu odtwarzania Okładka gatunku %s - Wyświetlanie oraz kontrolowanie odtwarzania muzyki + Podgląd i sterowanie odtwarzanianiem muzyki Regulacja w oparciu o tagi Regulacja bez tagów Wzmocnienie dźwięku przez preamplifier jest nakładane na wcześniej ustawione wzmocnienie podczas odtwarzania - Odtwarzając utwór ze szczegółów elementu - Odtwarzaj tylko z elementu - Zatrzymaj odtwarzanie kiedy utwór zostanie ponownie odtworzony - Muzyka zostanie załadowana tylko z wybranych folderów. + Odtwarzanie z widoku szczegółowego + Odtwórz tylko wybrane + Zatrzymaj odtwarzanie, kiedy utwór się powtórzy + Muzyka będzie importowana tylko z wybranych folderów. Znaki oddzielające wartości Wybierz znaki oddzielające poszczególne wartości w metadanych Auxio wymaga zgody na dostęp do twojej biblioteki muzycznej Ten folder nie jest wspierany Utwory nie są odtwarzane - Ładuję twoją bibliotekę muzyczną… (%1$d/%2$d) - Załadowane albumy: %d - Załadowane gatunki: %d + Importuję bibliotekę muzyczną… (%1$d/%2$d) + Zaimportowane albumy: %d + Zaimportowane gatunki: %d Łączny czas trwania: %s - Muzyka nie zostanie załadowana z wybranych folderów. + Muzyka nie będzie importowana z wybranych folderów. Zmień tryb powtarzania - Odtwarzaj losowo wszystkie utwory + Odtwórz losowo wszystkie utwory Monitoruję zmiany w bibliotece muzycznej… - Ładowanie muzyki + Importowanie utworów Monitoruję bibliotekę muzyczną Kontynuuj odtwarzanie losowe po wybraniu nowego utworu - Załadowane utwory: %d - Ignoruj pliki audio które nie są utworami muzycznymi, np. podcasty + Zaimportowane utwory: %d + Ignoruj pliki audio które nie są utworami muzycznymi (np. podcasty) Odtwórz od wykonawcy Wyklucz inne pliki dźwiękowe Okładki albumów Wyłączone - Szybkie + Niska jakość Wysoka jakość - Uwaga: To ustawienie może powodować nieprawidłowe przetwarzenie tagów jako posiadających wiele wartości. Problem ten należy rozwiązać stawiając ukośnik wsteczny (\\) przed niepożądanymi znakami rozdzielającymi. - Zmień motyw i kolory aplikacji - Ukryj współpracowników - Zarządzaj pobieraniem muzyki i obrazów - Księgozbiór - Konfigurowanie kontroli i zachowania interfejsu użytkownika - Pokaż tylko artystów, którzy są bezpośrednio przypisani do albumu (działa najlepiej w dobrze oznaczonych bibliotekach) + Uwaga: To ustawienie może powodować nieprawidłowe przetwarzenie tagów - tak, jakby posiadały wiele wartości. Problem ten należy rozwiązać stawiając ukośnik wsteczny (\\) przed niepożądanymi znakami traktowanymi jako oddzielające. + Dostosuj motyw i kolory aplikacji + Ukryj wykonawców uczestniczących + Zarządzaj importowaniem muzyki i obrazów + Biblioteka + Dostosuj elementy i funkcje interfejsu + Pokaż tylko artystów bezpośrednio przypisanych do albumu (działa najlepiej w przypadku dobrze otagowanych bibliotek) Odtwarzanie - Folder - Wytrwałość - Obraz - Dostosowywanie dźwięku i zachowania podczas odtwarzania + Foldery + Stan odtwarzania + Obrazy + Zarządzanie dźwiękiem i odtwarzaniem muzyki Odtwórz wybrane - Przetasuj wybrane - %d Wybrano + Wybrane losowo + Wybrano %d Wyrównanie głośności (ReplayGain) - Zresetować + Resetuj Wiki - Zachowanie - Graj według gatunku - Wyczyść pamięć podręczną tagów i całkowicie zaktualizuj bibliotekę (powoli, ale wydajniej) - Przeskanuj muzykę + Funkcje + Odtwórz z gatunku + Wyczyść pamięć cache z tagami i zaimportuj ponownie bibliotekę (wolniej, ale dokładniej) + Zaimportuj ponownie bibliotekę %d wykonawca %d wykonawcy @@ -273,9 +273,19 @@ %1$s, %2$s Muzyka - Nie można wyczyścić stanu + Nie można wyczyścić stanu odtwarzania Nie można zapisać stanu odtwarzania Malejąco - Ignoruj rodzajniki podczas sortowania - Ignoruj słowa takie jak „the” podczas sortowania według tytułu (działa najlepiej z tytułami w języku angielskim) + Playlisty + Playlista + Obraz playlisty %s + Inteligentne sortowanie + Ignoruj słowa takie jak „the” oraz numery w tytule podczas sortowania (działa najlepiej z utworami w języku angielskim) + Utwórz nową playlistę + Nowa playlista + Dodaj do playlisty + Utworzono playlistę + Brak utworów + Dodano do playlisty + Playlista %d \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 39f7febb4..b8e2b2539 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -123,7 +123,7 @@ Pular para a música anterior Alterar o modo de repetição Aleatorizar todas das músicas - Remover esta música da fila + Remover esta música da fila Limpar histórico de pesquisa Capa do álbum para %s Mover esta aba @@ -147,7 +147,7 @@ Áudio Matroska Codificação de Audio Avançada (AAC) Free Lossless Audio Codec (FLAC) - Mover esta música da fila + Mover esta música da fila Dinâmico Duração total: %s Carregando sua biblioteca de músicas… (%1$d/%2$d) diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 3e7f7a265..ff331f33f 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -98,7 +98,7 @@ O Auxio precisa de permissão para ler a sua biblioteca de músicas Sem pastas Esta pasta não é compatível - Mover esta música da fila + Mover esta música da fila Remover pasta Compilações de remix Compilação ao vivo @@ -195,7 +195,7 @@ Restaurar o estado de reprodução salvo anteriormente (se houver) Ativar ou desativar a reprodução aleatória Embaralhar todas as músicas - Remover esta música de fila + Remover esta música de fila Áudio Matroska Codificação de Audio Avançada (AAC) Álbum diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 4170d35de..1ef092e66 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -29,7 +29,7 @@ Dezvoltat de Alexander Capehart Setări - Aspect + Aspect și caracteristici Temă Automat Luminos @@ -134,4 +134,29 @@ Afişa Utilizați o temă întunecată pur-negru Coperți rotunjite ale albumelor + Redare selecție + Listă de redare + Liste de redare + Descrescător + Selecție aleatorie aleasă + Treceți la următoarea + Redă de la artist + Redă din genul + Resetează + Wiki + Vizualizați și controlați redarea muzicii + Schimbă vizibilitatea și ordinea taburilor din bibliotecă + Taburi din bibliotecă + Nu uita de shuffle + Redă din toate melodiile + În timpul redării din bibliotecă + Redă de la articolul afișat + Conținut + Acțiune de notificare personalizată + Menține funcția shuffle activată la redarea unei melodii noi + Personalizarea acțiunii bării de redare + Modul de repetare + Redă din album + În timpul redării de la detaliile articolului + Comportament \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bfc33bc2f..449dc9662 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -93,8 +93,8 @@ Режим повтора Перемешивание Перемешать все треки - Удалить трек из очереди - Переместить трек в очереди + Удалить трек из очереди + Переместить трек в очереди Переместить вкладку Очистить поисковый запрос Удалить папку @@ -248,7 +248,7 @@ Пользовательское поведение панели воспроизведения Пересканировать музыку Очистить кеш тегов и полностью обновить библиотеку (медленно, но более эффективно) - Сборники ремиксов + Сборник ремиксов %d исполнитель %d исполнителя @@ -278,6 +278,16 @@ Папки Состояние воспроизведения По убыванию + Плейлист + Плейлисты + Обложка плейлиста для %s Игнорировать артикли при сортировке Игнорировать такие слова, как «the», при сортировке по имени (лучше всего работает с англоязычной музыкой) + Создать новый плейлист + Новый плейлист + Плейлист %d + Добавить в плейлист + Без треков + Добавлено в плейлист + Плейлист создан \ No newline at end of file diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index a6b3daec9..bd7e5ac5e 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -1,2 +1,9 @@ - \ No newline at end of file + + Праћење музичке библиотеке + Покушај поново + Одобрити + Једноставан, рационалан музички плејер за android. + Музика се учитава + Учитавање музике + \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index a05a0adfd..db0b15c59 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -192,7 +192,7 @@ Eklendiği tarih Remix albüm Canlı albüm - Bu şarkıyı kuyruktan kaldır + Bu şarkıyı kuyruktan kaldır Tekliler Tekli Karışık kaset @@ -254,7 +254,7 @@ Müzik olmayanları hariç tut Durum temizlenemedi ReplayGain stratejisi - Bu şarkıyı kuyrukta taşı + Bu şarkıyı kuyrukta taşı %1$s, %2$s Müzik ve görüntülerin nasıl yükleneceğini denetleyin Müzik @@ -269,6 +269,10 @@ Arayüz kontrollerini ve davranışını özelleştirin Davranış Ses yüksekliği dengesi ReplayGain + %s için oynatma listesi resmi + oynatma listesi + çalma listeleri Sıralama yaparken makaleleri yoksay Ada göre sıralarken \"the\" gibi kelimeleri yok sayın (en iyi ingilizce müzikle çalışır) + Yeni bir oynatma listesi oluştur \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f15b96f58..e29bd03a1 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -67,10 +67,10 @@ Еквалайзер Розмір Завантаження музики - Збірки реміксів + Збірка реміксів Бітрейт Моніторинг музичної бібліотеки - Простий, раціональний музичний плеєр для Android. + Простий і раціональний музичний плеєр для Android. Завантаження музики Альбом Сингли @@ -82,7 +82,7 @@ Концертний альбом Шлях до каталогу Екран - Дата + Рік Відтворити вибране Обкладинки альбомів Приховати співавторів @@ -177,7 +177,7 @@ Музика буде завантажена тільки з вибраних папок. Відновити раніше збережений стан відтворення (якщо є) Регулювання на основі тегів - Вирівнювання гучності (ReplayGain) + Налаштування ReplayGain Зберегти стан відтворення Очистити стан відтворення Відновити стан відтворення @@ -204,7 +204,7 @@ Зупинити відтворення Вільний аудіокодек без втрат (FLAC) Темно-фіолетовий - %d Вибрано + Вибрано %d Завантаження музичної бібліотеки… (%1$d/%2$d) %d кбіт/с %d Гц @@ -218,8 +218,8 @@ Невідомий жанр Відкрити чергу Жовтий - Перемістити пісню в черзі - Видалити пісню з черги + Перемістити пісню + Видалити пісню Блакитний Зеленувато-блакитний Фіолетовий @@ -265,16 +265,34 @@ Поведінка Змініть тему та кольори застосунку Налаштуйте елементи керування та поведінку інтерфейсу користувача - Керування завантаженням музики та зображень + Керуйте завантаженням музики та зображень Музика Зображення Відтворення Вирівнювання гучності (ReplayGain) Бібліотека - Наполегливість - Налаштування звуку і поведінки при відтворенні + Стан відтворення + Налаштуйте звук і поведінку при відтворенні Папки За спаданням - Ігнорувати артиклі під час сортування - Ігнорування таких слів, як \"the\", під час сортування за назвою (найкраще працює з англомовною музикою) + Зображення списку відтворення для %s + Список відтворення + Списки відтворення + Інтелектуальне сортування + Ігнорування таких слів, як \"the\", або цифр під час сортування за назвою (найкраще працює з англомовною музикою) + Створити новий список відтворення + Новий список відтворення + Список відтворення %d + Додати до списку відтворення + Додано до списку відтворення + Список відтворення створено + Немає пісень + Видалити + Видалити список відтворення\? + Видалити %s\? Цю дію не можна скасувати. + Список відтворення видалено + Перейменувати + Перейменувати список відтворення + Список відтворення перейменовано + Редагувати \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index faa26ddf0..60ff2a42b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -93,8 +93,8 @@ 更改重复播放模式 开启或关闭随机播放模式 随机播放所有曲目 - 移除队列曲目 - 移动队列曲目 + 移除队列曲目 + 移动队列曲目 移动该标签 清除搜索队列 移除文件夹 @@ -269,6 +269,24 @@ 音乐 配置声音和播放行为 降序 + 播放列表 + 播放列表 + %s 的播放列表图片 排序时忽略冠词 按名称排序时忽略类似“the”这样的冠词(对英文歌曲的效果最好) + 创建新的播放列表 + 新建播放列表 + 播放列表 %d + 已创建播放列表 + 添加到播放列表 + 已添加到播放列表 + 无歌曲 + 删除 + 删除播放列表? + 删除 %s 吗?此操作无法撤销。 + 重命名 + 重命名播放列表 + 已重命名播放列表 + 已删除播放列表 + 编辑 \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 3b43e79b2..4ca96d8e0 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -5,6 +5,7 @@ + 200 100 diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 03936b846..5c745d67d 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -9,7 +9,7 @@ %d %1$s (%2$s) %s - %s - %1$s/%2$s + %1$s/%2$s Vorbis diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index 5971baebb..faab5d5cd 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -35,7 +35,7 @@ auxio_wipe_state auxio_restore_state - auxio_lib_tabs + auxio_home_tabs auxio_hide_collaborators auxio_round_covers auxio_bar_action @@ -47,6 +47,7 @@ auxio_albums_sort auxio_artists_sort auxio_genres_sort + auxio_playlists_sort auxio_album_sort auxio_artist_sort diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index db7b36a48..bf01fa3dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,7 +53,7 @@ Compilation Live compilation - Remix compilations + Remix compilation Soundtracks Soundtrack @@ -76,6 +76,15 @@ Genre Genres + Playlist + Playlists + New playlist + Rename + Rename playlist + Delete + Delete playlist? + Edit + Search @@ -106,6 +115,8 @@ Play next Add to queue + Add to playlist + Go to artist Go to album View properties @@ -155,6 +166,10 @@ Loading your music library… Monitoring your music library for changes… Added to queue + Playlist created + Playlist renamed + Playlist deleted + Added to playlist Developed by Alexander Capehart Search your library… @@ -293,10 +308,11 @@ Change repeat mode Turn shuffle on or off Shuffle all songs + Create a new playlist Stop playback - Remove this queue song - Move this queue song + Remove this song + Move this song Open the queue Move this tab Clear search query @@ -307,6 +323,7 @@ Album cover for %s Artist image for %s Genre image for %s + Playlist image for %s @@ -315,7 +332,9 @@ Unknown genre No date No track + No songs No music playing + There\'s nothing here yet @@ -369,6 +388,8 @@ Disc %d + + Playlist %d +%.1f dB @@ -380,6 +401,7 @@ %d Hz Loading your music library… (%1$d/%2$d) + Delete %s? This cannot be undone. Songs loaded: %d Albums loaded: %d diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt index c4992eca0..a3b3b4bac 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt @@ -18,21 +18,16 @@ package org.oxycblt.auxio.music -import android.content.Context import android.net.Uri -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.metadata.Date -import org.oxycblt.auxio.music.metadata.Disc -import org.oxycblt.auxio.music.metadata.ReleaseType -import org.oxycblt.auxio.music.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 open class FakeSong : Song { - override val rawName: String? - get() = throw NotImplementedError() - override val rawSortName: String? - get() = throw NotImplementedError() - override val sortName: SortName? + override val name: Name get() = throw NotImplementedError() override val date: Date? get() = throw NotImplementedError() @@ -60,18 +55,10 @@ open class FakeSong : Song { get() = throw NotImplementedError() override val uid: Music.UID get() = throw NotImplementedError() - - override fun resolveName(context: Context): String { - throw NotImplementedError() - } } open class FakeAlbum : Album { - override val rawName: String? - get() = throw NotImplementedError() - override val rawSortName: String? - get() = throw NotImplementedError() - override val sortName: SortName? + override val name: Name get() = throw NotImplementedError() override val coverUri: Uri get() = throw NotImplementedError() @@ -89,18 +76,10 @@ open class FakeAlbum : Album { get() = throw NotImplementedError() override val uid: Music.UID get() = throw NotImplementedError() - - override fun resolveName(context: Context): String { - throw NotImplementedError() - } } open class FakeArtist : Artist { - override val rawName: String? - get() = throw NotImplementedError() - override val rawSortName: String? - get() = throw NotImplementedError() - override val sortName: SortName? + override val name: Name get() = throw NotImplementedError() override val albums: List get() = throw NotImplementedError() @@ -114,18 +93,10 @@ open class FakeArtist : Artist { get() = throw NotImplementedError() override val uid: Music.UID get() = throw NotImplementedError() - - override fun resolveName(context: Context): String { - throw NotImplementedError() - } } open class FakeGenre : Genre { - override val rawName: String? - get() = throw NotImplementedError() - override val rawSortName: String? - get() = throw NotImplementedError() - override val sortName: SortName? + override val name: Name get() = throw NotImplementedError() override val albums: List get() = throw NotImplementedError() @@ -137,8 +108,4 @@ open class FakeGenre : Genre { get() = throw NotImplementedError() override val uid: Music.UID get() = throw NotImplementedError() - - override fun resolveName(context: Context): String { - throw NotImplementedError() - } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt new file mode 100644 index 000000000..4af3e64b3 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2023 Auxio Project + * FakeMusicRepository.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 kotlinx.coroutines.Job +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.user.UserLibrary + +open class FakeMusicRepository : MusicRepository { + override val indexingState: IndexingState? + get() = throw NotImplementedError() + override val deviceLibrary: DeviceLibrary? + get() = throw NotImplementedError() + override val userLibrary: UserLibrary? + get() = throw NotImplementedError() + + override fun addUpdateListener(listener: MusicRepository.UpdateListener) { + throw NotImplementedError() + } + + override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { + throw NotImplementedError() + } + + override fun addIndexingListener(listener: MusicRepository.IndexingListener) { + throw NotImplementedError() + } + + override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { + throw NotImplementedError() + } + + override fun registerWorker(worker: MusicRepository.IndexingWorker) { + throw NotImplementedError() + } + + override fun unregisterWorker(worker: MusicRepository.IndexingWorker) { + throw NotImplementedError() + } + + override fun find(uid: Music.UID): Music? { + throw NotImplementedError() + } + + override suspend fun createPlaylist(name: String, songs: List) { + throw NotImplementedError() + } + + override suspend fun renamePlaylist(playlist: Playlist, name: String) { + throw NotImplementedError() + } + + override suspend fun deletePlaylist(playlist: Playlist) { + throw NotImplementedError() + } + + override suspend fun addToPlaylist(songs: List, playlist: Playlist) { + throw NotImplementedError() + } + + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { + throw NotImplementedError() + } + + override fun requestIndex(withCache: Boolean) { + throw NotImplementedError() + } + + override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean): Job { + throw NotImplementedError() + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt index 024f443e5..66cd8e880 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt @@ -19,7 +19,7 @@ package org.oxycblt.auxio.music import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.music.storage.MusicDirectories +import org.oxycblt.auxio.music.fs.MusicDirectories open class FakeMusicSettings : MusicSettings { override fun registerListener(listener: MusicSettings.Listener) = throw NotImplementedError() @@ -48,6 +48,9 @@ open class FakeMusicSettings : MusicSettings { override var genreSort: Sort get() = throw NotImplementedError() set(_) = throw NotImplementedError() + override var playlistSort: Sort + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() override var albumSongSort: Sort get() = throw NotImplementedError() set(_) = throw NotImplementedError() diff --git a/app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt deleted file mode 100644 index 832bfee70..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * MusicRepositoryTest.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 org.junit.Assert.assertEquals -import org.junit.Test -import org.oxycblt.auxio.music.model.FakeLibrary -import org.oxycblt.auxio.music.model.Library - -class MusicRepositoryTest { - @Test - fun listeners() { - val listener = TestListener() - val impl = - MusicRepositoryImpl().apply { - library = null - addListener(listener) - } - impl.library = TestLibrary(0) - assertEquals(listOf(null, TestLibrary(0)), listener.updates) - - val listener2 = TestListener() - impl.addListener(listener2) - impl.library = TestLibrary(1) - assertEquals(listOf(TestLibrary(0), TestLibrary(1)), listener2.updates) - } - - private class TestListener : MusicRepository.Listener { - val updates = mutableListOf() - - override fun onLibraryChanged(library: Library?) { - updates.add(library) - } - } - - private data class TestLibrary(private val id: Int) : FakeLibrary() -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt index d9ff955bf..8ad02dbb6 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt @@ -21,32 +21,35 @@ package org.oxycblt.auxio.music import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import org.oxycblt.auxio.music.model.FakeLibrary -import org.oxycblt.auxio.music.system.FakeIndexer -import org.oxycblt.auxio.music.system.Indexer +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.device.FakeDeviceLibrary import org.oxycblt.auxio.util.forceClear class MusicViewModelTest { @Test fun indexerState() { val indexer = - TestIndexer().apply { state = Indexer.State.Indexing(Indexer.Indexing.Indeterminate) } - val musicViewModel = MusicViewModel(indexer) - assertTrue(indexer.listener is MusicViewModel) + TestMusicRepository().apply { + indexingState = IndexingState.Indexing(IndexingProgress.Indeterminate) + } + val musicViewModel = MusicViewModel(indexer, FakeMusicSettings()) + assertTrue(indexer.updateListener is MusicViewModel) + assertTrue(indexer.indexingListener is MusicViewModel) assertEquals( - Indexer.Indexing.Indeterminate, - (musicViewModel.indexerState.value as Indexer.State.Indexing).indexing) - indexer.state = null - assertEquals(null, musicViewModel.indexerState.value) + IndexingProgress.Indeterminate, + (musicViewModel.indexingState.value as IndexingState.Indexing).progress) + indexer.indexingState = null + assertEquals(null, musicViewModel.indexingState.value) musicViewModel.forceClear() - assertTrue(indexer.listener == null) + assertTrue(indexer.indexingListener == null) } @Test fun statistics() { - val indexer = - TestIndexer().apply { state = Indexer.State.Complete(Result.success(TestLibrary())) } - val musicViewModel = MusicViewModel(indexer) + val musicRepository = TestMusicRepository() + val musicViewModel = MusicViewModel(musicRepository, FakeMusicSettings()) + assertEquals(null, musicViewModel.statistics.value) + musicRepository.deviceLibrary = TestDeviceLibrary() assertEquals( MusicViewModel.Statistics( 2, @@ -60,38 +63,55 @@ class MusicViewModelTest { @Test fun requests() { - val indexer = TestIndexer() - val musicViewModel = MusicViewModel(indexer) + val indexer = TestMusicRepository() + val musicViewModel = MusicViewModel(indexer, FakeMusicSettings()) musicViewModel.refresh() musicViewModel.rescan() assertEquals(listOf(true, false), indexer.requests) } - private class TestIndexer : FakeIndexer() { - var listener: Indexer.Listener? = null - var state: Indexer.State? = null + private class TestMusicRepository : FakeMusicRepository() { + override var deviceLibrary: DeviceLibrary? = null set(value) { field = value - listener?.onIndexerStateChanged(value) + updateListener?.onMusicChanges( + MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) + } + override var indexingState: IndexingState? = null + set(value) { + field = value + indexingListener?.onIndexingStateChanged() } + var updateListener: MusicRepository.UpdateListener? = null + var indexingListener: MusicRepository.IndexingListener? = null val requests = mutableListOf() - override fun registerListener(listener: Indexer.Listener) { - this.listener = listener - listener.onIndexerStateChanged(state) + override fun addUpdateListener(listener: MusicRepository.UpdateListener) { + listener.onMusicChanges( + MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) + this.updateListener = listener } - override fun unregisterListener(listener: Indexer.Listener) { - this.listener = null + override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { + this.updateListener = null } - override fun requestReindex(withCache: Boolean) { + override fun addIndexingListener(listener: MusicRepository.IndexingListener) { + listener.onIndexingStateChanged() + this.indexingListener = listener + } + + override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { + this.indexingListener = null + } + + override fun requestIndex(withCache: Boolean) { requests.add(withCache) } } - private class TestLibrary : FakeLibrary() { + private class TestDeviceLibrary : FakeDeviceLibrary() { override val songs: List get() = listOf(TestSong(), TestSong()) override val albums: List diff --git a/app/src/test/java/org/oxycblt/auxio/music/model/RawMusicTest.kt b/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt similarity index 85% rename from app/src/test/java/org/oxycblt/auxio/music/model/RawMusicTest.kt rename to app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt index 9033aebd7..9227b93bf 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/model/RawMusicTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * RawMusicTest.kt is part of Auxio. + * DeviceMusicImplTest.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,41 +16,13 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.model +package org.oxycblt.auxio.music.device import java.util.* -import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode -import org.oxycblt.auxio.music.metadata.Date - -class RawMusicTest { - @Test - fun musicUid_auxio() { - val uid = - Music.UID.auxio(MusicMode.SONGS) { - update("Wheel") - update(listOf("Parannoul", "Asian Glow")) - update("Paraglow") - update(null as String?) - update(Date.from(2022)) - update(4 as Int?) - update(null as Int?) - } - - assertEquals("org.oxycblt.auxio:a10b-3d29c202-cd52-fbe0-4714-47cd07f07a59", uid.toString()) - } - - @Test - fun musicUid_musicBrainz() { - val uid = - Music.UID.musicBrainz( - MusicMode.ALBUMS, UUID.fromString("9b3b0695-0cdc-4560-8486-8deadee136cb")) - assertEquals("org.musicbrainz:a10a-9b3b0695-0cdc-4560-8486-8deadee136cb", uid.toString()) - } +class DeviceMusicImplTest { @Test fun albumRaw_equals_inconsistentCase() { val a = diff --git a/app/src/test/java/org/oxycblt/auxio/music/model/FakeLibrary.kt b/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt similarity index 77% rename from app/src/test/java/org/oxycblt/auxio/music/model/FakeLibrary.kt rename to app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt index 3144b4db0..93cbaa62c 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/model/FakeLibrary.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * FakeLibrary.kt is part of Auxio. + * FakeDeviceLibrary.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,13 +16,13 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.model +package org.oxycblt.auxio.music.device import android.content.Context import android.net.Uri import org.oxycblt.auxio.music.* -open class FakeLibrary : Library { +open class FakeDeviceLibrary : DeviceLibrary { override val songs: List get() = throw NotImplementedError() override val albums: List @@ -32,7 +32,7 @@ open class FakeLibrary : Library { override val genres: List get() = throw NotImplementedError() - override fun find(uid: Music.UID): T? { + override fun findSong(uid: Music.UID): Song? { throw NotImplementedError() } @@ -40,11 +40,15 @@ open class FakeLibrary : Library { throw NotImplementedError() } - override fun sanitize(parent: T): T? { + override fun findAlbum(uid: Music.UID): Album? { throw NotImplementedError() } - override fun sanitize(song: Song): Song? { + override fun findArtist(uid: Music.UID): Artist? { + throw NotImplementedError() + } + + override fun findGenre(uid: Music.UID): Genre? { throw NotImplementedError() } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt similarity index 98% rename from app/src/test/java/org/oxycblt/auxio/music/metadata/DateTest.kt rename to app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt index 4f9a48240..075df1b1c 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.metadata +package org.oxycblt.auxio.music.info import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/DiscTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt similarity index 97% rename from app/src/test/java/org/oxycblt/auxio/music/metadata/DiscTest.kt rename to app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt index 086c4ab57..260ca67cb 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/DiscTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.metadata +package org.oxycblt.auxio.music.info import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/ReleaseTypeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt similarity index 98% rename from app/src/test/java/org/oxycblt/auxio/music/metadata/ReleaseTypeTest.kt rename to app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt index eb6475430..9ca019a40 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/ReleaseTypeTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.metadata +package org.oxycblt.auxio.music.info import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt index d5cdb4ce2..6cd22fdcb 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt @@ -18,12 +18,12 @@ package org.oxycblt.auxio.music.metadata -import com.google.android.exoplayer2.metadata.Metadata -import com.google.android.exoplayer2.metadata.flac.PictureFrame -import com.google.android.exoplayer2.metadata.id3.ApicFrame -import com.google.android.exoplayer2.metadata.id3.InternalFrame -import com.google.android.exoplayer2.metadata.id3.TextInformationFrame -import com.google.android.exoplayer2.metadata.vorbis.VorbisComment +import androidx.media3.common.Metadata +import androidx.media3.extractor.metadata.flac.PictureFrame +import androidx.media3.extractor.metadata.id3.ApicFrame +import androidx.media3.extractor.metadata.id3.InternalFrame +import androidx.media3.extractor.metadata.id3.TextInformationFrame +import androidx.media3.extractor.metadata.vorbis.VorbisComment import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/org/oxycblt/auxio/music/system/FakeIndexer.kt b/app/src/test/java/org/oxycblt/auxio/music/system/FakeIndexer.kt deleted file mode 100644 index ce3bcf210..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/system/FakeIndexer.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeIndexer.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.system - -import android.content.Context -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job - -open class FakeIndexer : Indexer { - override val isIndeterminate: Boolean - get() = throw NotImplementedError() - override val isIndexing: Boolean - get() = throw NotImplementedError() - - override fun index(context: Context, withCache: Boolean, scope: CoroutineScope): Job { - throw NotImplementedError() - } - - override fun registerController(controller: Indexer.Controller) { - throw NotImplementedError() - } - - override fun unregisterController(controller: Indexer.Controller) { - throw NotImplementedError() - } - - override fun registerListener(listener: Indexer.Listener) { - throw NotImplementedError() - } - - override fun unregisterListener(listener: Indexer.Listener) { - throw NotImplementedError() - } - - override fun requestReindex(withCache: Boolean) { - throw NotImplementedError() - } - - override fun reset() { - throw NotImplementedError() - } -} diff --git a/build.gradle b/build.gradle index 2cbd23974..754d9b9a6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,8 +1,8 @@ buildscript { ext { - kotlin_version = '1.8.10' + kotlin_version = '1.8.21' navigation_version = "2.5.3" - hilt_version = '2.45' + hilt_version = '2.46.1' } repositories { @@ -11,10 +11,10 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' + classpath 'com.android.tools.build:gradle:8.0.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version" - classpath "com.diffplug.spotless:spotless-plugin-gradle:6.17.0" + classpath "com.diffplug.spotless:spotless-plugin-gradle:6.18.0" classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index a5e9581e5..9c89604d7 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -1,10 +1,10 @@ -Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, aber ohne die vielen unnötigen Funktionen, die andere Player haben. Auxio basiert auf Exoplayer und hat deshalb eine bessere Musik-Bibliotheks-Unterstützung und Qualität als andere Player, die die veralteten Android-Funktionen nutzen. Kurz gesagt, Auxio spielt Musik. +Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, aber ohne die vielen unnötigen Funktionen, die andere Player haben. Auxio basiert auf Exoplayer und besitzt daher eine erstklassige Musikbibliothek-Unterstützung sowie Wiedergabequalität verglichen mit anderen Playern, die veraltete Android-Funktionen nutzen. Kurz gesagt, Auxio spielt Musik. Funktionen -- auf ExoPlayer basierend -- einfache, an Material Design orientierte UI -- UX bevorzugt Einfachheit über spezifische Fälle +- auf ExoPlayer basierende Wiedergabe +- elegante, am Material Design orientierte UI +- Überzeugende UX, die eine einfache Bedienung über Grenzfälle stellt - Anpassbares Verhalten - Erweiterter Medien-Indexer, der korrekte Metadaten bevorzugt - Unterstützung für CD-Nummer, mehrere Künstler, Releasetypen, präzises/originales Datum, Tags-Sortierung und Release-Typ werden unterstützt (Experimentell) diff --git a/fastlane/metadata/android/de/short_description.txt b/fastlane/metadata/android/de/short_description.txt index 59414cc75..09b3a2ef5 100644 --- a/fastlane/metadata/android/de/short_description.txt +++ b/fastlane/metadata/android/de/short_description.txt @@ -1 +1 @@ -Eine simpler, rationaler Musikspieler +Ein simpler, rationaler Musikspieler diff --git a/fastlane/metadata/android/en-US/changelogs/30.txt b/fastlane/metadata/android/en-US/changelogs/30.txt new file mode 100644 index 000000000..bb065cf3d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/30.txt @@ -0,0 +1,2 @@ +Auxio 3.1.0 introduces playlisting functionality, with more features coming soon. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.1.0. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 196da8418..ec9c5977d 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,4 +1,4 @@ -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, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, It plays music. Features @@ -10,7 +10,8 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele 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 @@ -19,4 +20,4 @@ precise/original dates, sort tags, and more - Headset autoplay - Stylish widgets that automatically adapt to their size - Completely private and offline -- No rounded album covers (Unless you want them. Then you can.) +- No rounded album covers (Unless you want them. Then you can.) \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png index 9ec7120a7..6067c95ae 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png index 8c2e43141..f36816339 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png index ad22d8591..958fdbc4c 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png index 160dd4eb9..d12b24814 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png index 316d87445..e9c15227e 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png index c748df8c2..8fcac190a 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot6.png index 9614eedf9..cc9ef761e 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot6.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot6.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot7.png index e49b47bf4..c5b2a17ad 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot7.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot7.png differ diff --git a/fastlane/metadata/android/he/short_description.txt b/fastlane/metadata/android/he/short_description.txt new file mode 100644 index 000000000..f4d5ceb64 --- /dev/null +++ b/fastlane/metadata/android/he/short_description.txt @@ -0,0 +1 @@ +נגן מוזיקה פשוט והגיוני diff --git a/fastlane/metadata/android/hi/full_description.txt b/fastlane/metadata/android/hi/full_description.txt new file mode 100644 index 000000000..2b9dea82a --- /dev/null +++ b/fastlane/metadata/android/hi/full_description.txt @@ -0,0 +1,22 @@ +Auxio एक तेज़, विश्वसनीय UI/UX वाला एक स्थानीय संगीत प्लेयर है, जिसमें अन्य संगीत प्लेयर में मौजूद कई बेकार सुविधाएँ नहीं हैं। एक्सोप्लेयर से निर्मित, औक्सियो में पुराने एंड्रॉइड कार्यक्षमता का उपयोग करने वाले अन्य ऐप्स की तुलना में बेहतर पुस्तकालय समर्थन और सुनने की गुणवत्ता है। संक्षेप में, +यह संगीत बजाता है. + +विशेषताएं + +- ExoPlayer-आधारित प्लेबैक +- नवीनतम मटीरियल डिज़ाइन दिशानिर्देशों से प्राप्त स्नैपी UI +- ओपिनियनेटेड UX जो ओवर एज केस के उपयोग को प्राथमिकता देता है +- अनुकूलन योग्य व्यवहार +- डिस्क संख्या, एकाधिक कलाकार, रिलीज़ प्रकार, सटीक के लिए समर्थन / मूल दिनांक, सॉर्ट टैग, और बहुत कुछ +- उन्नत कलाकार प्रणाली जो कलाकारों और एल्बम कलाकारों को एकजुट करती है +- एसडी कार्ड-जागरूक फ़ोल्डर प्रबंधन +- विश्वसनीय प्लेबैक स्थिति दृढ़ता +- पूर्ण रीप्लेगैन समर्थन (MP3, FLAC, OGG, OPUS और MP4 फ़ाइलों पर) +- बाहरी तुल्यकारक समर्थन (उदा। वेवलेट) +- एज-टू-एज +- एंबेडेड कवर समर्थन +- खोज कार्यक्षमता +- हेडसेट ऑटोप्ले +- स्टाइलिश विजेट जो स्वचालित रूप से अपने आकार के अनुकूल हो जाते हैं +- पूरी तरह से निजी और ऑफ़लाइन +- कोई गोलाकार एल्बम कवर नहीं (जब तक आप उन्हें नहीं चाहते। फिर तुम कर सकते हो।) diff --git a/fastlane/metadata/android/hi/short_description.txt b/fastlane/metadata/android/hi/short_description.txt new file mode 100644 index 000000000..547a53dd6 --- /dev/null +++ b/fastlane/metadata/android/hi/short_description.txt @@ -0,0 +1 @@ +एक सरल, तर्कसंगत संगीत प्लेयर diff --git a/fastlane/metadata/android/hr/full_description.txt b/fastlane/metadata/android/hr/full_description.txt index 86158228f..ee782d658 100644 --- a/fastlane/metadata/android/hr/full_description.txt +++ b/fastlane/metadata/android/hr/full_description.txt @@ -1,20 +1,22 @@ -Auxio je lokalan izvođač glazbe sa brzim UI/UX na koji se možete osloniti, bez nepotrebnih značajki koje su prisutne u ostalim izvođačima glazbe. Kreiran od Exoplayer, Auxio vam daje mnogo bolje iskustvo slušanja u usporedbi s ostalim aplikacijama, koje koriste uobičajen MediaPlayer API. Ukratko, reproducira glazbu. +Auxio je lokalni izvođač glazbe s brzim i pouzdanim korisničkim sučeljem/korisničkim iskustvom bez nepotrebnih značajki koje su prisutne u ostalim izvođačima glazbe. Kreiran od Exoplayera, Auxio ima vrhunsku podršku za biblioteke i kvalitetu slušanja u usporedbi s drugim aplikacijama koje koriste zastarjelu funkcionalnost Androida. Ukratko, reproducira glazbu. Značajke - Reprodukcija bazirana na ExoPlayeru -- Elegantan UI u skladu s najnovijim Materijalnim Dizajnom -- UX koji naglašava bolju lakoću korištenja, nasuprot kompleksnim postavkama -- Prilagodljive radnje aplikacije -- Napredan čitač medija koji naglašava točnost metapodataka -- Svjestan SD kartice, te tako oprezno raspoređuje mape -- Izvođač na koji se možete osloniti da krenuti tamo gdje ste posljednji put stali +- Brzo korisničko sučelje u skladu s najnovijim Materijal dizajnom +- Korisničko iskustvo koje priorizira jednostavnost korištenja +- Prilagodljive ponašanje aplikacije +- Podrška za brojeve diskova, izvođače, vrste izdanja, +precizne/izvorne datume, oznake razvrstavanje i još više +- Napredni sustav izvođača koji ujedinjuje izvođače i izvođače albuma +- Upravljanje mapama SD kartica +- Pouzdana postojanost stanja reprodukcije - Potpuna ReplayGain podrška (Za MP3, MP4, FLAC, OGG, i OPUS formate) -- Funkcionalnost eksternog ekvilajzera (u programima kao Wavelet) +- Podrška za eksterne ekvilajzere (npr. Wavelet) - Prikaz od ruba do ruba -- Podrška za ugrađene naslovnice -- Pretražite svoju glazbu +- Podrška za ugrađene omote +- Pretražinje - Mogućnost pokretanja glazbe čim spojite slušalice -- Stilizirani prečaci koji automatski prilagođavaju svoju veličinu -- U potpunosti privatan i nije mu potreban internet -- Nema zaobljenih naslovnica albuma (Osim ako ih želite. Onda ih možete imati.) +- Stilizirani widgeti koji automatski prilagođavaju svoju veličinu +- Potpuno privatan bez potrebe za internetskom vezom +- Bez zaobljenih omota albuma (Osim ako ih želite. Onda ih možete imati.) diff --git a/fastlane/metadata/android/ml/full_description.txt b/fastlane/metadata/android/ml/full_description.txt new file mode 100644 index 000000000..9950c406e --- /dev/null +++ b/fastlane/metadata/android/ml/full_description.txt @@ -0,0 +1,19 @@ +മറ്റ് മ്യൂസിക് പ്ലെയറുകളിലുള്ള ഉപയോഗശൂന്യമായ നിരവധി ഫീച്ചറുകൾ ഇല്ലാതെ വേഗതയേറിയതും വിശ്വസനീയവുമായ UI/UX ഉള്ള ഒരു ലോക്കൽ മ്യൂസിക് പ്ലെയറാണ് Auxio. Exoplayer-ൽ നിന്ന് നിർമ്മിച്ച Auxio, കാലഹരണപ്പെട്ട Android പ്രവർത്തനക്ഷമത ഉപയോഗിക്കുന്ന മറ്റ് ആപ്പുകളെ അപേക്ഷിച്ച് മികച്ച ലൈബ്രറി പിന്തുണയും ശ്രവണ നിലവാരവും ഉണ്ട്. ചുരുക്കത്തിൽ ,ഇത് സംഗീതം പ്ലേ ചെയ്യുന്നു. + +സവിശേഷതകൾ + +- ExoPlayer-അടിസ്ഥാനത്തിലുള്ള പ്ലേബാക്ക് +- ഏറ്റവും പുതിയ മെറ്റീരിയൽ ഡിസൈൻ മാർഗ്ഗനിർദ്ദേശങ്ങളിൽ നിന്ന് ഉരുത്തിരിഞ്ഞ സ്‌നാപ്പി യുഐ +- എഡ്ജ് കേസുകളിൽ എളുപ്പത്തിലുള്ള ഉപയോഗത്തിന് മുൻഗണന നൽകുന്ന അഭിപ്രായമുള്ള UX +- ഇഷ്‌ടാനുസൃതമാക്കാവുന്ന പെരുമാറ്റം +- ഡിസ്ക് നമ്പറുകൾ, ഒന്നിലധികം ആർട്ടിസ്റ്റുകൾ, റിലീസ് തരങ്ങൾ, കൃത്യമായ/ഒറിജിനൽ തീയതികൾ, അടുക്കൽ ടാഗുകൾ എന്നിവയും അതിലേറെയും പിന്തുണ +- ആർട്ടിസ്റ്റുകളെയും ആൽബം ആർട്ടിസ്റ്റുകളെയും ഏകീകരിക്കുന്ന നൂതന ആർട്ടിസ്റ്റ് സിസ്റ്റം +- SD കാർഡ്-അവെയർ ഫോൾഡർ മാനേജ്മെന്റ് +- വിശ്വസനീയമായ പ്ലേബാക്ക് നില സ്ഥിരത +- പൂർണ്ണ റീപ്ലേഗെയിൻ പിന്തുണ (MP3-ൽ , FLAC, OGG, OPUS, MP4 ഫയലുകൾ) -എക്‌സ്റ്റേണൽ ഇക്വലൈസർ പിന്തുണ (ഉദാ. വേവ്‌ലെറ്റ്) +- എഡ്ജ്-ടു-എഡ്ജ് - ഉൾച്ചേർത്ത കവറുകൾ പിന്തുണ +- തിരയൽ പ്രവർത്തനം +- ഹെഡ്‌സെറ്റ് ഓട്ടോപ്ലേ +- സ്വയമേവ അവയുടെ വലുപ്പവുമായി പൊരുത്തപ്പെടുന്ന സ്റ്റൈലിഷ് വിജറ്റുകൾ +- പൂർണ്ണമായും സ്വകാര്യവും ഓഫ്‌ലൈനും +- വൃത്താകൃതിയിലുള്ള ആൽബം കവറുകൾ ഇല്ല (നിങ്ങൾക്ക് അവ ആവശ്യമില്ലെങ്കിൽ. അപ്പോൾ നിങ്ങൾക്ക് കഴിയും.) diff --git a/fastlane/metadata/android/pa/full_description.txt b/fastlane/metadata/android/pa/full_description.txt new file mode 100644 index 000000000..1fc6c904d --- /dev/null +++ b/fastlane/metadata/android/pa/full_description.txt @@ -0,0 +1,19 @@ +Auxio ਇੱਕ ਤੇਜ਼, ਭਰੋਸੇਮੰਦ UI/UX ਵਾਲਾ ਇੱਕ ਸਥਾਨਕ ਸੰਗੀਤ ਪਲੇਅਰ ਹੈ ਜੋ ਦੂਜੇ ਸੰਗੀਤ ਪਲੇਅਰਾਂ ਵਿੱਚ ਮੌਜੂਦ ਬਹੁਤ ਸਾਰੀਆਂ ਬੇਕਾਰ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਤੋਂ ਬਿਨਾਂ ਹੈ। Exoplayer ਤੋਂ ਬਣਿਆ, Auxio ਕੋਲ ਪੁਰਾਣੀ ਐਂਡਰੌਇਡ ਕਾਰਜਕੁਸ਼ਲਤਾ ਦੀ ਵਰਤੋਂ ਕਰਨ ਵਾਲੀਆਂ ਹੋਰ ਐਪਾਂ ਦੇ ਮੁਕਾਬਲੇ ਵਧੀਆ ਲਾਇਬ੍ਰੇਰੀ ਸਹਾਇਤਾ ਅਤੇ ਸੁਣਨ ਦੀ ਗੁਣਵੱਤਾ ਹੈ। ਸੰਖੇਪ ਵਿੱਚ, ਇਹ ਸੰਗੀਤ ਚਲਾਉਂਦਾ ਹੈ. + +ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ + +- ExoPlayer-ਅਧਾਰਿਤ ਪਲੇਬੈਕ +- ਨਵੀਨਤਮ ਸਮੱਗਰੀ ਡਿਜ਼ਾਈਨ ਦਿਸ਼ਾ-ਨਿਰਦੇਸ਼ਾਂ ਤੋਂ ਲਿਆ ਗਿਆ Snappy UI +- ਓਪੀਨੀਏਟਿਡ UX ਜੋ ਕਿ ਕਿਨਾਰੇ ਕੇਸਾਂ 'ਤੇ ਵਰਤੋਂ ਵਿੱਚ ਆਸਾਨੀ ਨੂੰ ਤਰਜੀਹ ਦਿੰਦਾ ਹੈ +- ਅਨੁਕੂਲਿਤ ਵਿਵਹਾਰ +- ਡਿਸਕ ਨੰਬਰਾਂ, ਮਲਟੀਪਲ ਕਲਾਕਾਰਾਂ, ਰੀਲੀਜ਼ ਕਿਸਮਾਂ, ਸਟੀਕ ਲਈ ਸਮਰਥਨ /ਮੂਲ ਤਾਰੀਖਾਂ, ਕ੍ਰਮਬੱਧ ਟੈਗਸ, ਅਤੇ ਹੋਰ +- ਉੱਨਤ ਕਲਾਕਾਰ ਪ੍ਰਣਾਲੀ ਜੋ ਕਲਾਕਾਰਾਂ ਅਤੇ ਐਲਬਮ ਕਲਾਕਾਰਾਂ ਨੂੰ ਇਕਜੁੱਟ ਕਰਦੀ ਹੈ +- SD ਕਾਰਡ-ਜਾਣੂ ਫੋਲਡਰ ਪ੍ਰਬੰਧਨ - ਭਰੋਸੇਯੋਗ ਪਲੇਬੈਕ ਸਥਿਤੀ ਸਥਿਰਤਾ - ਪੂਰਾ ਰੀਪਲੇਗੇਨ ਸਮਰਥਨ (MP3, FLAC, OGG, OPUS, ਅਤੇ MP4 ਫਾਈਲਾਂ 'ਤੇ) +- ਬਾਹਰੀ ਈਕੋਲਾਈਜ਼ਰ ਦਾ ਸਮਰਥਨ (ਉਦਾਹਰਨ. ਵੇਵਲੇਟ) +- ਕਿਨਾਰੇ-ਤੋਂ-ਕਿਨਾਰੇ +- ਏਮਬੈਡਡ ਕਵਰ ਸਪੋਰਟ +- ਖੋਜ ਕਾਰਜਸ਼ੀਲਤਾ +- ਹੈੱਡਸੈੱਟ ਆਟੋਪਲੇ +- ਸਟਾਈਲਿਸ਼ ਵਿਜੇਟਸ ਜੋ ਆਪਣੇ ਆਪ ਉਹਨਾਂ ਦੇ ਆਕਾਰ ਦੇ ਅਨੁਕੂਲ ਬਣਦੇ ਹਨ +- ਪੂਰੀ ਤਰ੍ਹਾਂ ਨਿੱਜੀ ਅਤੇ ਔਫਲਾਈਨ +- ਕੋਈ ਗੋਲ ਐਲਬਮ ਕਵਰ ਨਹੀਂ (ਜਦੋਂ ਤੱਕ ਤੁਸੀਂ ਉਹਨਾਂ ਨੂੰ ਨਹੀਂ ਚਾਹੁੰਦੇ ਹੋ। ਤੁਸੀਂ ਕਰ ਸੱਕਦੇ ਹੋ।) diff --git a/fastlane/metadata/android/pa/short_description.txt b/fastlane/metadata/android/pa/short_description.txt new file mode 100644 index 000000000..f8179d9a9 --- /dev/null +++ b/fastlane/metadata/android/pa/short_description.txt @@ -0,0 +1 @@ +ਇੱਕ ਸਧਾਰਨ, ਤਰਕਸ਼ੀਲ ਸੰਗੀਤ ਪਲੇਅਰ diff --git a/gradle.properties b/gradle.properties index f6e147bcb..8e89fa623 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,3 +19,7 @@ android.useAndroidX=true android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official +android.enableR8.fullMode=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f..c1962a79e 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e7af72a0b..2c3425d49 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Sun Feb 19 08:14:58 MST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionSha256Sum=e111cb9948407e26351227dabce49822fb88c37ee72f1d1582a69c68af2e702f +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index a69d9cb6c..aeb74cbb4 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -143,12 +140,16 @@ fi if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,6 +194,10 @@ if "$cygwin" || "$msys" ; then done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in diff --git a/gradlew.bat b/gradlew.bat index 53a6b238d..6689b85be 100755 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% diff --git a/media b/media new file mode 160000 index 000000000..8712967a7 --- /dev/null +++ b/media @@ -0,0 +1 @@ +Subproject commit 8712967a789192d60d2207451cd5ed2b3191999e diff --git a/settings.gradle b/settings.gradle index df2dcbd45..c8be53d93 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,5 @@ include ':app' rootProject.name = "Auxio" -gradle.ext.exoplayerModulePrefix = 'exoplayer-' -apply from: file("ExoPlayer/core_settings.gradle") \ No newline at end of file +gradle.ext.androidxMediaModulePrefix = 'media-' +gradle.ext.androidxMediaProjectName = 'media-' +apply from: file("media/core_settings.gradle") \ No newline at end of file