From e4339a76bf5a25ec6800cf48fe51dea9dc6ba28a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 19 Mar 2023 19:50:34 -0600 Subject: [PATCH 01/88] playlist: add runtime boilerplate Add the boilerplate code for the playlist system. Any functionality is runtime only and is not integrated in-app. Plan is to build the UI scaffold first followed by the backing playlist. --- .../org/oxycblt/auxio/music/system/Indexer.kt | 16 ++-- .../playback/persist/PersistenceDatabase.kt | 2 +- .../org/oxycblt/auxio/playlist/Playlist.kt | 29 +++++++ .../oxycblt/auxio/playlist/PlaylistModule.kt | 31 ++++++++ .../auxio/playlist/PlaylistRepository.kt | 77 +++++++++++++++++++ 5 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playlist/PlaylistModule.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playlist/PlaylistRepository.kt 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 index 321724d2c..8928a059e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -144,9 +144,11 @@ interface Indexer { * * @param result The outcome of the music loading process. */ - data class Complete(val result: Result) : State() + data class Complete(val result: Result) : State() } + data class Response(val result: Library, val playlists: List) + /** * Represents the current progress of the music loader. Usually encapsulated in a [State]. * @@ -237,7 +239,7 @@ constructor( private val mediaStoreExtractor: MediaStoreExtractor, private val tagExtractor: TagExtractor ) : Indexer { - @Volatile private var lastResponse: Result? = null + @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 @@ -303,11 +305,11 @@ constructor( val result = try { val start = System.currentTimeMillis() - val library = indexImpl(context, withCache, this) + val response = indexImpl(context, withCache, this) logD( "Music indexing completed successfully in " + "${System.currentTimeMillis() - start}ms") - Result.success(library) + Result.success(response) } catch (e: CancellationException) { // Got cancelled, propagate upwards to top-level co-routine. logD("Loading routine was cancelled") @@ -337,7 +339,7 @@ constructor( context: Context, withCache: Boolean, scope: CoroutineScope - ): Library { + ): Indexer.Response { if (ContextCompat.checkSelfPermission(context, Indexer.PERMISSION_READ_AUDIO) == PackageManager.PERMISSION_DENIED) { logE("Permission check failed") @@ -395,7 +397,7 @@ constructor( if (cache == null || cache.invalidated) { cacheRepository.writeCache(rawSongs) } - return libraryJob.await() + return Indexer.Response(libraryJob.await(), listOf()) } /** @@ -426,7 +428,7 @@ constructor( * @param result The new [Result] to emit, representing the outcome of the music loading * process. */ - private suspend fun emitCompletion(result: Result) { + 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. 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..3ac52c1fd 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 @@ -40,7 +40,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.Converter::class) abstract class PersistenceDatabase : RoomDatabase() { /** * Get the current [PlaybackStateDao]. diff --git a/app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt b/app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt new file mode 100644 index 000000000..d7e4d9303 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 Auxio Project + * Playlist.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.playlist + +import java.util.UUID +import org.oxycblt.auxio.music.Song + +interface Playlist { + val id: UUID + val name: String + val songs: List +} + diff --git a/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistModule.kt b/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistModule.kt new file mode 100644 index 000000000..fba4dc489 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistModule.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistModule.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.playlist + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface PlaylistModule { + @Binds + fun playlistRepository(playlistRepositoryImpl: PlaylistRepositoryImpl): PlaylistRepository +} diff --git a/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistRepository.kt b/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistRepository.kt new file mode 100644 index 000000000..90fcb5f0c --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistRepository.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistRepository.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.playlist + +import java.util.UUID +import javax.inject.Inject +import org.oxycblt.auxio.music.Song + +interface PlaylistRepository { + val playlists: List + suspend fun createPlaylist(name: String, songs: List) + suspend fun deletePlaylist(playlist: Playlist) + suspend fun addToPlaylist(playlist: Playlist, songs: List) + suspend fun removeFromPlaylist(playlist: Playlist, song: Song) + suspend fun rewritePlaylist(playlist: Playlist, songs: List) +} + +class PlaylistRepositoryImpl @Inject constructor() : PlaylistRepository { + private val playlistMap = mutableMapOf() + override val playlists: List + get() = playlistMap.values.toList() + + override suspend fun createPlaylist(name: String, songs: List) { + val uuid = UUID.randomUUID() + playlistMap[uuid] = PlaylistImpl(uuid, name, songs) + } + + override suspend fun deletePlaylist(playlist: Playlist) { + playlistMap.remove(playlist.id) + } + + override suspend fun addToPlaylist(playlist: Playlist, songs: List) { + editPlaylist(playlist) { + addAll(songs) + this + } + } + + override suspend fun removeFromPlaylist(playlist: Playlist, song: Song) { + editPlaylist(playlist) { + remove(song) + this + } + } + + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { + editPlaylist(playlist) { songs } + } + + private inline fun editPlaylist(playlist: Playlist, edits: MutableList.() -> List) { + check(playlistMap.containsKey(playlist.id)) { "Invalid playlist argument provided" } + playlistMap[playlist.id] = + PlaylistImpl(playlist.id, playlist.name, edits(playlist.songs.toMutableList())) + } +} + +private data class PlaylistImpl( + override val id: UUID, + override val name: String, + override val songs: List +) : Playlist From c6898aa3cc1b96bb8382101accc8ed1808f5a762 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 19 Mar 2023 20:04:18 -0600 Subject: [PATCH 02/88] build: fix failure Due to a sudden pivot to starting from repository backwards with playlists, there were some half-baked changesets lying around that I forgot to revert. Do that. --- CHANGELOG.md | 1 + .../java/org/oxycblt/auxio/music/system/Indexer.kt | 12 +++++------- .../auxio/playback/persist/PersistenceDatabase.kt | 2 +- .../main/java/org/oxycblt/auxio/playlist/Playlist.kt | 1 - 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc55cf4cc..df765a896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ rather than on start/stop - 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 ## 3.0.3 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 index 8928a059e..a5daf2118 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -144,11 +144,9 @@ interface Indexer { * * @param result The outcome of the music loading process. */ - data class Complete(val result: Result) : State() + data class Complete(val result: Result) : State() } - data class Response(val result: Library, val playlists: List) - /** * Represents the current progress of the music loader. Usually encapsulated in a [State]. * @@ -239,7 +237,7 @@ constructor( private val mediaStoreExtractor: MediaStoreExtractor, private val tagExtractor: TagExtractor ) : Indexer { - @Volatile private var lastResponse: Result? = null + @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 @@ -339,7 +337,7 @@ constructor( context: Context, withCache: Boolean, scope: CoroutineScope - ): Indexer.Response { + ): Library { if (ContextCompat.checkSelfPermission(context, Indexer.PERMISSION_READ_AUDIO) == PackageManager.PERMISSION_DENIED) { logE("Permission check failed") @@ -397,7 +395,7 @@ constructor( if (cache == null || cache.invalidated) { cacheRepository.writeCache(rawSongs) } - return Indexer.Response(libraryJob.await(), listOf()) + return libraryJob.await() } /** @@ -428,7 +426,7 @@ constructor( * @param result The new [Result] to emit, representing the outcome of the music loading * process. */ - private suspend fun emitCompletion(result: Result) { + 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. 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 3ac52c1fd..a61731213 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 @@ -40,7 +40,7 @@ import org.oxycblt.auxio.playback.state.RepeatMode entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class], version = 27, exportSchema = false) -@TypeConverters(Music.UID.Converter::class) +@TypeConverters(PersistenceDatabase.Converters::class) abstract class PersistenceDatabase : RoomDatabase() { /** * Get the current [PlaybackStateDao]. diff --git a/app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt b/app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt index d7e4d9303..62697fc8e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt +++ b/app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt @@ -26,4 +26,3 @@ interface Playlist { val name: String val songs: List } - From 5e3b4a2fce04d2a07e1507575482fc3e3497ee1a Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 20 Mar 2023 16:06:01 +0100 Subject: [PATCH 03/88] Translations update from Hosted Weblate (#387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Arabic (ar_IQ)) Currently translated at 58.9% (151 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ar_IQ/ * Added translation using Weblate (Hebrew) * Translated using Weblate (Hebrew) Currently translated at 96.5% (28 of 29 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/he/ * Translated using Weblate (Hebrew) Currently translated at 61.7% (158 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/he/ * Translated using Weblate (German) Currently translated at 100.0% (256 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Polish) Currently translated at 100.0% (256 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/ * Translated using Weblate (Polish) Currently translated at 100.0% (256 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/ * Translated using Weblate (Polish) Currently translated at 100.0% (256 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/ * Translated using Weblate (German) Currently translated at 100.0% (256 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (German) Currently translated at 100.0% (29 of 29 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/de/ * Translated using Weblate (Galician) Currently translated at 98.0% (251 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/gl/ * Translated using Weblate (Polish) Currently translated at 100.0% (256 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/ * Translated using Weblate (Polish) Currently translated at 100.0% (256 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/ * Translated using Weblate (Polish) Currently translated at 100.0% (256 of 256 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/ --------- Co-authored-by: Moayad Ahmed Co-authored-by: FAYE Co-authored-by: qwerty287 Co-authored-by: Maciej Klupp Co-authored-by: Макар Разин Co-authored-by: C. Rüdinger Co-authored-by: gallegonovato --- app/src/main/res/values-ar-rIQ/strings.xml | 70 ++++++- app/src/main/res/values-de/strings.xml | 18 +- app/src/main/res/values-gl/strings.xml | 5 + app/src/main/res/values-iw/strings.xml | 162 ++++++++++++++++ app/src/main/res/values-pl/strings.xml | 176 +++++++++--------- .../metadata/android/de/full_description.txt | 8 +- .../metadata/android/de/short_description.txt | 2 +- .../metadata/android/he/short_description.txt | 1 + 8 files changed, 332 insertions(+), 110 deletions(-) create mode 100644 app/src/main/res/values-iw/strings.xml create mode 100644 fastlane/metadata/android/he/short_description.txt diff --git a/app/src/main/res/values-ar-rIQ/strings.xml b/app/src/main/res/values-ar-rIQ/strings.xml index 284a89bab..3844f401a 100644 --- a/app/src/main/res/values-ar-rIQ/strings.xml +++ b/app/src/main/res/values-ar-rIQ/strings.xml @@ -6,11 +6,11 @@ إعادة المحاولة منح - انواع + الانواع فنانين البومات اغاني - جميع الاغاني + كل الاغاني بحث تصفية الكل @@ -18,27 +18,27 @@ اسم فنان البوم - سنة + التاريخ تصاعدي - يعمل الآن + يتم التشغيل الان تشغيل - خلط + عشوائي تشغيل من جميع الاغاني تشغيل من البوم تشغيل من فنان طابور - شغل تالياً + شغل الاغنية التالية أضف إلى الطابور تمت الإضافة إلى الطابور أذهب إلى الفنان أذهب إلى الالبوم تم حفظ الحالة أضف - احفظ + حفظ لا مجلد حول الإصدار - عرض على جيتهاب + عرض على الكود في Github التراخيص تمت برمجة التطبيق من قبل OxygenCobalt @@ -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-de/strings.xml b/app/src/main/res/values-de/strings.xml index d82e0fa38..f41ce9065 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 @@ -138,13 +138,13 @@ 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,7 +156,7 @@ 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. @@ -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 @@ -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 diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 9d62c8e9e..60f2ec924 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -255,4 +255,9 @@ 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 (\\). \ No newline at end of file diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml new file mode 100644 index 000000000..9765c8d11 --- /dev/null +++ b/app/src/main/res/values-iw/strings.xml @@ -0,0 +1,162 @@ + + + מוזיקה נטענת + מוזיקה נטענת + נסה~י שוב + מפקח על ספריית המוזיקה + כל השירים + אלבומים + אלבום חי + אלבום רמיקסים + אלבומי EP + EP + EP חי + EP רמיקסים + סינגלים + סינגל + סינגל חי + אוסף + אוסף חי + אוספי רמיקסים + פסקולים + פסקול + מיקסטייפים + מיקס + חי + רמיקסים + אומן + אומנים + ז\'אנר + ז\'אנרים + סינון + הכל + תאריך + כמות שירים + דיסק + תאריך הוספה + מיון + עולה + יורד + מנוגן כעת + איקוולייזר + נגנ~י + נגנ~י נבחרים + ערבוב + ערבוב נבחרים + נגנ~י את הבא + הוספ~י לתור + מעבר לאלבום + הצגת מאפיינים + מאפייני שיר + פורמט + גודל + קצב סיביות (ביטרייט) + קצב דגימה (סאמפל רייט) + ערבב~י הכל + אישור + ביטול + שמירה + אתחול + הוספ~י + המצב שנשמר + גרסה + קוד מקור + ויקי + רשיונות + סטטיסטיקות ספרייה + צפייה ושליטה בהשמעת המוזיקה + טוען את ספריית המוזיקה שלך… + משגיח על ספריית המוזיקה שלך כדי לאתר שינויים… + התווסף לרשימה + מפותח על ידי אלכסנדר קייפהארט + חפש~י בספרייה שלך… + מראה ותחושה + שנה~י את ערכת הנושא והצבעים של היישום + ערכת נושא + בהיר + כהה + סכמת צבעים + ערכת נושא שחורה + השתמש~י בערכת נושא שחורה לגמרי + מצב עגול + התאמה אישית + התאמ~י את בקרי והתנהגות הממשק + צג + לשוניות ספרייה + פעולת התראות מותאמת אישית + דלג~י לבא + מצב חזרה + התנהגות + כאשר מנוגן מהספרייה + כאשר מנוגן מפרטי הפריט + נגנ~י מהפריט המוצג + נגנ~י מכל השירים + נגנ~י מאלבום + נגנ~י מהאומן + נגנ~י מז\'אנר + זכור~י ערבוב + שמור~י על ערבוב פועל בעת הפעלת שיר חדש + תוכן + טעינה מחדש אוטומטית + טענ~י את הספריה מחדש בכל פעם שהיא משתנה (דורש התראה קבועה) + התעלמ~י מקבצי אודיו שאינם מוזיקה, כמו פודקאסטים (הסכתים) + מפרידים רבי-ערכים + פסיק (,) + נקודה-פסיק (;) + פלוס (+) + ו- (&) + החבא~י משתפי~ות פעולה + הראה~י רק אומנים שמצויינים ישירות בקרדיטים של אלבום (עובד באופן הטוב ביותר על ספריות מתוייגות היטב) + עטיפות אלבומים + כבוי + מהיר + אודיו + השמעה + ניגון אוטומטי באוזניות + הרצה לאחור לפני דילוג אחורה + הריצ~י לאחור לפני דילוג לשיר הקודם + עצירה בעת חזרה + עוצמת נגינה מחדש + העדפ~י אלבום + מגבר עוצמת נגינה מחדש + התאמה עם תגיות + מיקסטייפ + נגן מוזיקה פשוט והגיוני לאנדרואיד. + אלבום + אוספים + שירים + סינגל רמיקס + מיקסים + חיפוש + אורך + שם + רצועה + תור + מעבר לאומן + שם קובץ + ערבב~י + מצב שוחזר + אודות + הגדרות + אוטומטי + הפעל~י פינות מעוגלות ברכיבי ממשק נוספים (עטיפות אלבומים נדרשות להיות מעוגלות) + שנה~י את הנראות והסדר של לשוניות הספרייה + פעולת סרגל השמעה מותאמת אישית + קבע~י איך מוזיקה ותמונות נטענים + מוזיקה + אי-הכללת תוכן שאינו מוזיקה + התאמ~י תווים המציינים ערכי תגית מרובים + קו נטוי (/) + אזהרה: השימוש בהגדרה זו עלול לגרום לחלק מהתגיות להיות מפורשות באופן שגוי כבעלות מספר ערכים. ניתן לפתור זאת על ידי הכנסת קו נטוי אחורי (\\) לפני תווים מפרידים לא רצויים. + איכות גבוהה + התעלמ~י ממילים כמו \"The\" (\"ה-\") בעת סידור על פי שם (עובד באופן הכי טוב עם מוזיקה בשפה האנגלית) + תמונות + התאמ~י התנהגות צליל והשמעה + התחל~י לנגן תמיד ברגע שמחוברות אוזניות (עלול לא לעבוד בכל המערכות) + עצר~י כאשר שיר חוזר + העדפ~י רצועה + אסטרטגיית עוצמת נגינה מחדש + העדפ~י אלבום אם אחד מופעל + התאמה ללא תגיות + המגבר מוחל על ההתאמה הקיימת בזמן השמעה + \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 831df3cb3..b8180e778 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,54 +125,54 @@ 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 + Odśwież bibliotekę muzyczną używając tagów z pamięci cache, jeśli to możliwe 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 + Zawrzyj Zmień pozycję utworu w kolejce Przesuń kartę Wizerunek wykonawcy dla %s @@ -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,9 @@ %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 artykuły podczas sortowania - Ignoruj słowa takie jak „the” podczas sortowania według nazwy (działa najlepiej z muzyką w języku angielskim) + 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) \ No newline at end of file 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/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 @@ +נגן מוזיקה פשוט והגיוני From 4033a791a79c2984eee7a7c9200a8984c7fd8430 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 20 Mar 2023 11:32:13 -0600 Subject: [PATCH 04/88] playlist: consider playlists music Consider playlists music rather than an extension of music. This also sets up the basics of a playlist datbaase. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 2 +- .../auxio/detail/GenreDetailFragment.kt | 8 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 2 +- .../org/oxycblt/auxio/home/HomeViewModel.kt | 2 +- .../list/selection/SelectionViewModel.kt | 3 +- .../java/org/oxycblt/auxio/music/Music.kt | 16 ++++ .../oxycblt/auxio/music/MusicRepository.kt | 2 +- .../auxio/music/cache/CacheDatabase.kt | 2 +- .../auxio/music/cache/CacheRepository.kt | 2 +- .../auxio/music/{model => library}/Library.kt | 2 +- .../music/{model => library}/MusicImpl.kt | 2 +- .../music/{model => library}/RawMusic.kt | 2 +- .../auxio/music/metadata/TagExtractor.kt | 2 +- .../oxycblt/auxio/music/metadata/TagWorker.kt | 2 +- .../playlist/PlaylistDatabase.kt} | 24 ++++-- .../auxio/music/playlist/PlaylistImpl.kt | 33 ++++++++ .../{ => music}/playlist/PlaylistModule.kt | 21 +++-- .../auxio/music/playlist/RawPlaylist.kt | 41 ++++++++++ .../music/storage/MediaStoreExtractor.kt | 2 +- .../org/oxycblt/auxio/music/system/Indexer.kt | 4 +- .../oxycblt/auxio/picker/PickerViewModel.kt | 2 +- .../auxio/playback/PlaybackViewModel.kt | 2 + .../playback/persist/PersistenceDatabase.kt | 11 +-- .../playback/persist/PersistenceRepository.kt | 2 +- .../auxio/playback/system/PlaybackService.kt | 2 +- .../auxio/playlist/PlaylistRepository.kt | 77 ------------------- .../oxycblt/auxio/search/SearchFragment.kt | 8 +- .../oxycblt/auxio/search/SearchViewModel.kt | 2 +- .../auxio/music/MusicRepositoryTest.kt | 4 +- .../oxycblt/auxio/music/MusicViewModelTest.kt | 2 +- .../music/{model => library}/FakeLibrary.kt | 2 +- .../music/{model => library}/RawMusicTest.kt | 2 +- 32 files changed, 154 insertions(+), 136 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{model => library}/Library.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{model => library}/MusicImpl.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{model => library}/RawMusic.kt (99%) rename app/src/main/java/org/oxycblt/auxio/{playlist/Playlist.kt => music/playlist/PlaylistDatabase.kt} (55%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistImpl.kt rename app/src/main/java/org/oxycblt/auxio/{ => music}/playlist/PlaylistModule.kt (57%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/playlist/RawPlaylist.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/playlist/PlaylistRepository.kt rename app/src/test/java/org/oxycblt/auxio/music/{model => library}/FakeLibrary.kt (97%) rename app/src/test/java/org/oxycblt/auxio/music/{model => library}/RawMusicTest.kt (99%) 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..af6b1ca4d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -36,10 +36,10 @@ 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.library.Library 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.playback.PlaybackSettings import org.oxycblt.auxio.util.* 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..78a2ff97d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -38,12 +38,7 @@ 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.playback.PlaybackViewModel import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.util.* @@ -233,6 +228,7 @@ class GenreDetailFragment : is Genre -> { navModel.exploreNavigationItem.consume() } + is Playlist -> TODO("handle this") null -> {} } } 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..f20b31a8f 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -55,7 +55,7 @@ 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.library.Library import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.MainNavigationAction 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 aa3058f31..c7c3f6e1d 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,7 @@ 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.music.library.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent 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..7971712ea 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,7 @@ 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.music.library.Library /** * A [ViewModel] that manages the current selection. @@ -57,6 +57,7 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR is Album -> library.sanitize(it) is Artist -> library.sanitize(it) is Genre -> library.sanitize(it) + is Playlist -> TODO("handle this") } } } 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 0495e556e..e4b825201 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -21,6 +21,7 @@ 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 @@ -136,6 +137,14 @@ sealed interface Music : Item { MUSICBRAINZ("org.musicbrainz") } + object TypeConverters { + /** @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) + } + companion object { /** * Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective, @@ -356,6 +365,13 @@ interface Genre : MusicParent { val durationMs: Long } +/** + * A playlist. + * + * @author Alexander Capehart (OxygenCobalt) + */ +interface Playlist : 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". 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..08e7e6452 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -19,7 +19,7 @@ package org.oxycblt.auxio.music import javax.inject.Inject -import org.oxycblt.auxio.music.model.Library +import org.oxycblt.auxio.music.library.Library /** * A repository granting access to the music library. 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..325736e85 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.library.RawSong import org.oxycblt.auxio.music.metadata.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..8cfa1f28d 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.library.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/library/Library.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/model/Library.kt rename to app/src/main/java/org/oxycblt/auxio/music/library/Library.kt index 4f3b217c8..36a5ef7be 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/model/Library.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.model +package org.oxycblt.auxio.music.library import android.content.Context import android.net.Uri diff --git a/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/library/MusicImpl.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt rename to app/src/main/java/org/oxycblt/auxio/music/library/MusicImpl.kt index 682c011fe..975fc7933 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/MusicImpl.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.model +package org.oxycblt.auxio.music.library import android.content.Context import androidx.annotation.VisibleForTesting diff --git a/app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt rename to app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt index fa2042a60..e8acbad7e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.model +package org.oxycblt.auxio.music.library import java.util.UUID import org.oxycblt.auxio.music.* 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..d6e66c094 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 @@ -22,7 +22,7 @@ import com.google.android.exoplayer2.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.library.RawSong /** * The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the 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..62f717d9f 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 @@ -25,7 +25,7 @@ import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.TrackGroupArray import java.util.concurrent.Future import javax.inject.Inject -import org.oxycblt.auxio.music.model.RawSong +import org.oxycblt.auxio.music.library.RawSong import org.oxycblt.auxio.music.storage.toAudioUri import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW diff --git a/app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt b/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistDatabase.kt similarity index 55% rename from app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt rename to app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistDatabase.kt index 62697fc8e..652f0be74 100644 --- a/app/src/main/java/org/oxycblt/auxio/playlist/Playlist.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistDatabase.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * Playlist.kt is part of Auxio. + * PlaylistDatabase.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,21 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playlist +package org.oxycblt.auxio.music.playlist -import java.util.UUID -import org.oxycblt.auxio.music.Song +import androidx.room.* +import org.oxycblt.auxio.music.Music -interface Playlist { - val id: UUID - val name: String - val songs: List +@Database( + entities = [PlaylistInfo::class, PlaylistSong::class, PlaylistSongCrossRef::class], + version = 28, + exportSchema = false) +@TypeConverters(Music.UID.TypeConverters::class) +abstract class PlaylistDatabase : RoomDatabase() { + abstract fun playlistDao(): PlaylistDao +} + +@Dao +interface PlaylistDao { + @Transaction @Query("SELECT * FROM PlaylistInfo") fun readRawPlaylists(): List } diff --git a/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistImpl.kt new file mode 100644 index 000000000..dcb80a237 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistImpl.kt @@ -0,0 +1,33 @@ +/* + * 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.playlist + +import android.content.Context +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.library.Library + +class PlaylistImpl(rawPlaylist: RawPlaylist, library: Library, musicSettings: MusicSettings) : + Playlist { + override val uid = rawPlaylist.playlistInfo.playlistUid + override val rawName = rawPlaylist.playlistInfo.name + override fun resolveName(context: Context) = rawName + override val rawSortName = null + override val sortName = SortName(rawName, musicSettings) + override val songs = rawPlaylist.songs.mapNotNull { library.find(it.songUid) } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistModule.kt b/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistModule.kt similarity index 57% rename from app/src/main/java/org/oxycblt/auxio/playlist/PlaylistModule.kt rename to app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistModule.kt index fba4dc489..45da01dc7 100644 --- a/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistModule.kt @@ -16,16 +16,27 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playlist +package org.oxycblt.auxio.music.playlist -import dagger.Binds +import android.content.Context +import androidx.room.Room 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 PlaylistModule { - @Binds - fun playlistRepository(playlistRepositoryImpl: PlaylistRepositoryImpl): PlaylistRepository +class PlaylistModule { + @Provides fun playlistDao(database: PlaylistDatabase) = database.playlistDao() + + @Provides + fun playlistDatabase(@ApplicationContext context: Context) = + Room.databaseBuilder( + context.applicationContext, PlaylistDatabase::class.java, "playlists.db") + .fallbackToDestructiveMigration() + .fallbackToDestructiveMigrationFrom(0) + .fallbackToDestructiveMigrationOnDowngrade() + .build() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/playlist/RawPlaylist.kt b/app/src/main/java/org/oxycblt/auxio/music/playlist/RawPlaylist.kt new file mode 100644 index 000000000..58f232f47 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/playlist/RawPlaylist.kt @@ -0,0 +1,41 @@ +/* + * 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.playlist + +import androidx.room.* +import org.oxycblt.auxio.music.Music + +data class RawPlaylist( + @Embedded val playlistInfo: PlaylistInfo, + @Relation( + parentColumn = "playlistUid", + entityColumn = "songUid", + associateBy = Junction(PlaylistSongCrossRef::class)) + val songs: List +) + +@Entity data class PlaylistInfo(@PrimaryKey val playlistUid: Music.UID, val name: String) + +@Entity data class PlaylistSong(@PrimaryKey val songUid: Music.UID) + +@Entity(primaryKeys = ["playlistUid", "songUid"]) +data class PlaylistSongCrossRef( + val playlistUid: Music.UID, + @ColumnInfo(index = true) val songUid: Music.UID +) diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt index 1669ae516..5ea3a6cbf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt @@ -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.library.RawSong import org.oxycblt.auxio.music.metadata.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 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 index a5daf2118..1fd305c63 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -37,9 +37,9 @@ import kotlinx.coroutines.yield import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.cache.CacheRepository +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.RawSong 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 diff --git a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt index c10892dd5..e670078f5 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt @@ -24,7 +24,7 @@ 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.music.library.Library import org.oxycblt.auxio.util.unlikelyToBeNull /** 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..0bf724abf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -287,6 +287,7 @@ constructor( is Genre -> musicSettings.genreSongSort is Artist -> musicSettings.artistSongSort is Album -> musicSettings.albumSongSort + is Playlist -> TODO("handle this") null -> musicSettings.songSort } val queue = sort.songs(parent?.songs ?: library.songs) @@ -494,6 +495,7 @@ constructor( is Artist -> musicSettings.artistSongSort.songs(it.songs) is Genre -> musicSettings.genreSongSort.songs(it.songs) is Song -> listOf(it) + is Playlist -> TODO("handle this") } } } 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..8c6e59a6d 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) - } } /** 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..38cbba829 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.library.Library import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD 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..8f4480845 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 @@ -48,7 +48,7 @@ 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.music.library.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor diff --git a/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistRepository.kt b/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistRepository.kt deleted file mode 100644 index 90fcb5f0c..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playlist/PlaylistRepository.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * PlaylistRepository.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.playlist - -import java.util.UUID -import javax.inject.Inject -import org.oxycblt.auxio.music.Song - -interface PlaylistRepository { - val playlists: List - suspend fun createPlaylist(name: String, songs: List) - suspend fun deletePlaylist(playlist: Playlist) - suspend fun addToPlaylist(playlist: Playlist, songs: List) - suspend fun removeFromPlaylist(playlist: Playlist, song: Song) - suspend fun rewritePlaylist(playlist: Playlist, songs: List) -} - -class PlaylistRepositoryImpl @Inject constructor() : PlaylistRepository { - private val playlistMap = mutableMapOf() - override val playlists: List - get() = playlistMap.values.toList() - - override suspend fun createPlaylist(name: String, songs: List) { - val uuid = UUID.randomUUID() - playlistMap[uuid] = PlaylistImpl(uuid, name, songs) - } - - override suspend fun deletePlaylist(playlist: Playlist) { - playlistMap.remove(playlist.id) - } - - override suspend fun addToPlaylist(playlist: Playlist, songs: List) { - editPlaylist(playlist) { - addAll(songs) - this - } - } - - override suspend fun removeFromPlaylist(playlist: Playlist, song: Song) { - editPlaylist(playlist) { - remove(song) - this - } - } - - override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { - editPlaylist(playlist) { songs } - } - - private inline fun editPlaylist(playlist: Playlist, edits: MutableList.() -> List) { - check(playlistMap.containsKey(playlist.id)) { "Invalid playlist argument provided" } - playlistMap[playlist.id] = - PlaylistImpl(playlist.id, playlist.name, edits(playlist.songs.toMutableList())) - } -} - -private data class PlaylistImpl( - override val id: UUID, - override val name: String, - override val songs: List -) : Playlist 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..ce118eee4 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -36,12 +36,7 @@ import org.oxycblt.auxio.databinding.FragmentSearchBinding 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.playback.PlaybackViewModel import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.util.* @@ -155,6 +150,7 @@ class SearchFragment : ListFragment() { 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 Playlist -> TODO("handle this") } } 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..a36b475c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -33,7 +33,7 @@ import org.oxycblt.auxio.list.BasicHeader 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.library.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD diff --git a/app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt index 832bfee70..fe18dc326 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt @@ -20,8 +20,8 @@ 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 +import org.oxycblt.auxio.music.library.FakeLibrary +import org.oxycblt.auxio.music.library.Library class MusicRepositoryTest { @Test 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..078624d69 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt @@ -21,7 +21,7 @@ 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.library.FakeLibrary import org.oxycblt.auxio.music.system.FakeIndexer import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.util.forceClear diff --git a/app/src/test/java/org/oxycblt/auxio/music/model/FakeLibrary.kt b/app/src/test/java/org/oxycblt/auxio/music/library/FakeLibrary.kt similarity index 97% rename from app/src/test/java/org/oxycblt/auxio/music/model/FakeLibrary.kt rename to app/src/test/java/org/oxycblt/auxio/music/library/FakeLibrary.kt index 3144b4db0..7230e5e76 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/model/FakeLibrary.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/library/FakeLibrary.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.model +package org.oxycblt.auxio.music.library import android.content.Context import android.net.Uri diff --git a/app/src/test/java/org/oxycblt/auxio/music/model/RawMusicTest.kt b/app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt similarity index 99% rename from app/src/test/java/org/oxycblt/auxio/music/model/RawMusicTest.kt rename to app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt index 9033aebd7..a4fafb324 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/model/RawMusicTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.model +package org.oxycblt.auxio.music.library import java.util.* import org.junit.Assert.assertEquals From 9a282e2be9d1bd9bd94f781020924142d0a1e15a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 20 Mar 2023 15:26:22 -0600 Subject: [PATCH 05/88] music: unify indexer and repository Unify Indexer and MusicRepository into a single class. This is meant to create a single dependency on PlaylistDatabase and reduce the amount of orchestration. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 15 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 104 ++-- .../org/oxycblt/auxio/home/HomeViewModel.kt | 51 +- .../list/selection/SelectionViewModel.kt | 15 +- .../java/org/oxycblt/auxio/music/Indexing.kt | 82 ++++ .../org/oxycblt/auxio/music/MusicModule.kt | 3 - .../oxycblt/auxio/music/MusicRepository.kt | 334 +++++++++++-- .../org/oxycblt/auxio/music/MusicViewModel.kt | 46 +- .../org/oxycblt/auxio/music/system/Indexer.kt | 446 ------------------ .../music/system/IndexerNotifications.kt | 19 +- .../auxio/music/system/IndexerService.kt | 139 +++--- .../oxycblt/auxio/picker/PickerViewModel.kt | 15 +- .../auxio/playback/system/PlaybackService.kt | 13 +- .../oxycblt/auxio/search/SearchViewModel.kt | 11 +- .../auxio/music/FakeMusicRepository.kt | 72 +++ .../auxio/music/MusicRepositoryTest.kt | 53 --- .../oxycblt/auxio/music/MusicViewModelTest.kt | 65 ++- .../oxycblt/auxio/music/system/FakeIndexer.kt | 58 --- 18 files changed, 689 insertions(+), 852 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/Indexing.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt create mode 100644 app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/system/FakeIndexer.kt 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 af6b1ca4d..050b5de16 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -36,7 +36,6 @@ 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.library.Library import org.oxycblt.auxio.music.metadata.AudioInfo import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.ReleaseType @@ -57,7 +56,7 @@ constructor( private val audioInfoProvider: AudioInfo.Provider, private val musicSettings: MusicSettings, private val playbackSettings: PlaybackSettings -) : ViewModel(), MusicRepository.Listener { +) : ViewModel(), MusicRepository.UpdateListener { private var currentSongJob: Job? = null // --- SONG --- @@ -152,18 +151,16 @@ 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 (!changes.library) return + val library = musicRepository.library ?: return // 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 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 f20b31a8f..0b1bb4824 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -55,8 +55,6 @@ 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.library.Library -import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel @@ -158,7 +156,7 @@ class HomeFragment : 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) } @@ -340,14 +338,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 +353,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 } } } 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 c7c3f6e1d..081343cdb 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.library.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. */ @@ -117,37 +116,37 @@ 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") - // 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) - _albumsInstructions.put(UpdateInstructions.Diff) - _albumsLists.value = musicSettings.albumSort.albums(library.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 } - } else { - library.artists - }) - _genresInstructions.put(UpdateInstructions.Diff) - _genresList.value = musicSettings.genreSort.genres(library.genres) - } + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.library) return + val library = musicRepository.library ?: return + logD("Library changed, 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) + _albumsInstructions.put(UpdateInstructions.Diff) + _albumsLists.value = musicSettings.albumSort.albums(library.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 } + } else { + library.artists + }) + _genresInstructions.put(UpdateInstructions.Diff) + _genresList.value = musicSettings.genreSort.genres(library.genres) } override fun onTabsChanged() { @@ -159,7 +158,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(library = true, playlists = false)) } /** 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 7971712ea..6104ebee1 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.library.Library /** * A [ViewModel] that manages the current selection. @@ -33,21 +32,19 @@ import org.oxycblt.auxio.music.library.Library */ @HiltViewModel class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) : - ViewModel(), MusicRepository.Listener { + 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.library) return + val library = musicRepository.library ?: return // Sanitize the selection to remove items that no longer exist and thus // won't appear in any list. _selected.value = @@ -64,7 +61,7 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR override fun onCleared() { super.onCleared() - musicRepository.removeListener(this) + musicRepository.removeUpdateListener(this) } /** 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..7741136a8 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt @@ -0,0 +1,82 @@ +/* + * 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/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 08e7e6452..07bbe4214 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,327 @@ 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 kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.RawSong +import org.oxycblt.auxio.music.metadata.TagExtractor +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 /** - * 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 immutable music library loaded from the file-system. */ + val library: Library? + /** The current mutable user-defined playlists loaded from the file-system. */ + val playlists: List? + /** 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. - * - * @param listener The [Listener] to add. - * @see Listener + * Add an [UpdateListener] to receive updates from this instance. + * @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. - * - * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in - * the first place. - * @see Listener + * Remove an [UpdateListener] such that it does not receive any further updates from this + * instance. + * @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) + + /** + * 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. - * - * @param library The new [Library], or null if no [Library] has been loaded yet. + * Called when a change to the stored music information occurs. + * @param changes The [Changes] that have occured. */ - fun onLibraryChanged(library: Library?) + fun onMusicChanges(changes: Changes) + } + /** + * Flags indicating which kinds of music information changed. + * @param library Whether the current [Library] has changed. + * @param playlists Whether the current [Playlist]s have changed. + */ + data class Changes(val library: Boolean, val playlists: 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 musicSettings: MusicSettings, + private val cacheRepository: CacheRepository, + private val mediaStoreExtractor: MediaStoreExtractor, + private val tagExtractor: TagExtractor +) : MusicRepository { + private val updateListeners = mutableListOf() + private val indexingListeners = mutableListOf() + private var indexingWorker: MusicRepository.IndexingWorker? = null - @Volatile override var library: Library? = null - set(value) { - field = value - for (callback in listeners) { - callback.onLibraryChanged(library) + override var playlists: List? = null + private var previousCompletedState: IndexingState.Completed? = null + private var currentIndexingState: IndexingState? = null + override val indexingState: IndexingState? + get() = currentIndexingState ?: previousCompletedState + + @Synchronized + override fun addUpdateListener(listener: MusicRepository.UpdateListener) { + updateListeners.add(listener) + } + + @Synchronized + override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { + updateListeners.remove(listener) + } + + @Synchronized + override fun addIndexingListener(listener: MusicRepository.IndexingListener) { + indexingListeners.add(listener) + } + + @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 + } + + 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.async { mediaStoreExtractor.query() } + val cache = + if (withCache) { + cacheRepository.readCache() + } else { + null + } + val query = mediaStoreQueryJob.await() + + // 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.async { + mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) + } + val metadataJob = + worker.scope.async { tagExtractor.consume(incompleteSongs, completeSongs) } + + // 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() + metadataJob.await() + + if (rawSongs.isEmpty()) { + logE("Music library was empty") + throw NoMusicException() + } + + // Successfully loaded the library, now save the cache and create the library in + // parallel. + logD("Discovered ${rawSongs.size} songs, starting finalization") + emitLoading(IndexingProgress.Indeterminate) + val libraryJob = + worker.scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) } + if (cache == null || cache.invalidated) { + cacheRepository.writeCache(rawSongs) + } + val newLibrary = libraryJob.await() + withContext(Dispatchers.Main) { + emitComplete(null) + emitData(newLibrary, listOf()) + } + } + + 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(library: Library, playlists: List) { + val libraryChanged = this.library != library + val playlistsChanged = this.playlists != playlists + if (!libraryChanged && !playlistsChanged) return + + this.library = library + this.playlists = playlists + val changes = MusicRepository.Changes(libraryChanged, playlistsChanged) + for (listener in updateListeners) { + listener.onMusicChanges(changes) + } } } 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..6c4ab3680 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -23,7 +23,6 @@ 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.system.Indexer /** * A [ViewModel] providing data specific to the music loading process. @@ -31,12 +30,12 @@ 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) : + 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. */ @@ -44,36 +43,39 @@ class MusicViewModel @Inject constructor(private val indexer: Indexer) : get() = _statistics 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.library) return + val library = musicRepository.library ?: return + _statistics.value = + Statistics( + library.songs.size, + library.albums.size, + library.artists.size, + library.genres.size, + library.songs.sumOf { it.durationMs }) + } + + override fun onIndexingStateChanged() { + _indexingState.value = musicRepository.indexingState } /** Requests that the music library should be re-loaded while leveraging the cache. */ 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) } /** 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 1fd305c63..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ /dev/null @@ -1,446 +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 kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield -import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.cache.CacheRepository -import org.oxycblt.auxio.music.library.Library -import org.oxycblt.auxio.music.library.RawSong -import org.oxycblt.auxio.music.metadata.TagExtractor -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 response = indexImpl(context, withCache, this) - logD( - "Music indexing completed successfully in " + - "${System.currentTimeMillis() - start}ms") - Result.success(response) - } 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.async { mediaStoreExtractor.query() } - val cache = - if (withCache) { - cacheRepository.readCache() - } else { - null - } - val query = mediaStoreQueryJob.await() - - // 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.async { - mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) - } - val metadataJob = scope.async { 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() - metadataJob.await() - - 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.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) } - if (cache == null || cache.invalidated) { - cacheRepository.writeCache(rawSongs) - } - return libraryJob.await() - } - - /** - * 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..a12e252fb 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,13 +28,12 @@ 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.* import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.service.ForegroundManager @@ -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) { + if (!changes.library) return + val library = musicRepository.library ?: 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(library::sanitize), + queueState = + savedState.queueState.remap { song -> + library.sanitize(requireNotNull(song)) + }, + 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/picker/PickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt index e670078f5..cda23118c 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.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.library.Library import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -35,7 +34,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull */ @HiltViewModel class PickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : - ViewModel(), MusicRepository.Listener { + ViewModel(), MusicRepository.UpdateListener { private val _currentItem = MutableStateFlow(null) /** The current item whose artists should be shown in the picker. Null if there is no item. */ @@ -52,12 +51,16 @@ class PickerViewModel @Inject constructor(private val musicRepository: MusicRepo val genreChoices: StateFlow> get() = _genreChoices - override fun onCleared() { - musicRepository.removeListener(this) + init { + musicRepository.addUpdateListener(this) } - override fun onLibraryChanged(library: Library?) { - if (library != null) { + override fun onCleared() { + musicRepository.removeUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.library && musicRepository.library != null) { refreshChoices() } } 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 8f4480845..5cc4c89fb 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 @@ -48,7 +48,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.library.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor @@ -82,7 +81,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 +147,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 +186,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() @@ -299,10 +298,8 @@ class PlaybackService : playbackManager.next() } - // --- MUSICSTORE OVERRIDES --- - - override fun onLibraryChanged(library: Library?) { - if (library != null) { + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.library && musicRepository.library != null) { // We now have a library, see if we have anything we need to do. playbackManager.requestAction(this) } 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 a36b475c3..295eab901 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -50,7 +50,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 +64,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.library && musicRepository.library != null) { search(lastQuery) } } 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..3a11d25df --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt @@ -0,0 +1,72 @@ +/* + * 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.library.Library + +open class FakeMusicRepository : MusicRepository { + override var indexingState: IndexingState? + get() = throw NotImplementedError() + set(_) { + throw NotImplementedError() + } + override var library: Library? + get() = throw NotImplementedError() + set(_) { + throw NotImplementedError() + } + override var playlists: List? + get() = throw NotImplementedError() + set(_) { + 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 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/MusicRepositoryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt deleted file mode 100644 index fe18dc326..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.library.FakeLibrary -import org.oxycblt.auxio.music.library.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 078624d69..92b2534b0 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt @@ -22,31 +22,34 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.oxycblt.auxio.music.library.FakeLibrary -import org.oxycblt.auxio.music.system.FakeIndexer -import org.oxycblt.auxio.music.system.Indexer +import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.util.forceClear class MusicViewModelTest { @Test fun indexerState() { val indexer = - TestIndexer().apply { state = Indexer.State.Indexing(Indexer.Indexing.Indeterminate) } + TestMusicRepository().apply { + indexingState = IndexingState.Indexing(IndexingProgress.Indeterminate) + } val musicViewModel = MusicViewModel(indexer) - assertTrue(indexer.listener is MusicViewModel) + 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) + assertEquals(null, musicViewModel.statistics.value) + musicRepository.library = TestLibrary() assertEquals( MusicViewModel.Statistics( 2, @@ -60,33 +63,49 @@ class MusicViewModelTest { @Test fun requests() { - val indexer = TestIndexer() + val indexer = TestMusicRepository() val musicViewModel = MusicViewModel(indexer) 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 library: Library? = null set(value) { field = value - listener?.onIndexerStateChanged(value) + updateListener?.onMusicChanges( + MusicRepository.Changes(library = true, playlists = 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(library = true, playlists = 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) } } 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() - } -} From 686290a6c1712ad128df44cfdd398d1106037832 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 20 Mar 2023 19:35:28 -0600 Subject: [PATCH 06/88] playlist: add basic ui support Add extremely basic UI support for playlists. --- .../java/org/oxycblt/auxio/IntegerTable.kt | 28 ++-- .../auxio/detail/GenreDetailFragment.kt | 2 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 19 +-- .../org/oxycblt/auxio/home/HomeSettings.kt | 20 +++ .../org/oxycblt/auxio/home/HomeViewModel.kt | 63 +++++--- .../auxio/home/list/ArtistListFragment.kt | 2 +- .../auxio/home/list/GenreListFragment.kt | 2 +- .../auxio/home/list/PlaylistListFragment.kt | 141 ++++++++++++++++++ .../auxio/home/tabs/AdaptiveTabStrategy.kt | 4 + .../java/org/oxycblt/auxio/home/tabs/Tab.kt | 24 +-- .../org/oxycblt/auxio/home/tabs/TabAdapter.kt | 1 + .../org/oxycblt/auxio/image/ImageGroup.kt | 13 +- .../org/oxycblt/auxio/image/ImageModule.kt | 4 +- .../oxycblt/auxio/image/StyledImageView.kt | 14 +- .../auxio/image/extractor/Components.kt | 34 +++-- .../org/oxycblt/auxio/list/ListFragment.kt | 34 +++++ .../main/java/org/oxycblt/auxio/list/Sort.kt | 57 ++++--- .../auxio/list/recycler/ViewHolders.kt | 54 +++++++ .../java/org/oxycblt/auxio/music/Indexing.kt | 15 +- .../java/org/oxycblt/auxio/music/Music.kt | 7 +- .../java/org/oxycblt/auxio/music/MusicMode.kt | 6 +- .../oxycblt/auxio/music/MusicRepository.kt | 49 +++--- .../org/oxycblt/auxio/music/MusicSettings.kt | 13 ++ .../auxio/music/playlist/PlaylistImpl.kt | 3 + .../auxio/playback/PlaybackViewModel.kt | 1 + .../oxycblt/auxio/search/SearchFragment.kt | 6 +- .../oxycblt/auxio/search/SearchViewModel.kt | 1 + app/src/main/res/drawable/ic_playlist_24.xml | 11 ++ ...st_actions.xml => menu_parent_actions.xml} | 0 app/src/main/res/values/attrs.xml | 1 + app/src/main/res/values/donottranslate.xml | 2 +- app/src/main/res/values/settings.xml | 3 +- app/src/main/res/values/strings.xml | 4 + 33 files changed, 505 insertions(+), 133 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt create mode 100644 app/src/main/res/drawable/ic_playlist_24.xml rename app/src/main/res/menu/{menu_artist_actions.xml => menu_parent_actions.xml} (100%) diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index d2d1e933f..47a4d9781 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -33,18 +33,20 @@ 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 /** SortHeaderViewHolder */ - const val VIEW_TYPE_SORT_HEADER = 0xA005 + const val VIEW_TYPE_SORT_HEADER = 0xA006 /** AlbumSongViewHolder */ const val VIEW_TYPE_ALBUM_SONG = 0xA007 /** ArtistAlbumViewHolder */ - const val VIEW_TYPE_ARTIST_ALBUM = 0xA009 + const val VIEW_TYPE_ARTIST_ALBUM = 0xA008 /** ArtistSongViewHolder */ - const val VIEW_TYPE_ARTIST_SONG = 0xA00A + const val VIEW_TYPE_ARTIST_SONG = 0xA009 /** DiscHeaderViewHolder */ - const val VIEW_TYPE_DISC_HEADER = 0xA00C + const val VIEW_TYPE_DISC_HEADER = 0xA00A /** "Music playback" notification code */ const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0 /** "Music loading" notification code */ @@ -65,16 +67,16 @@ 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 + /** 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.ByName */ const val SORT_BY_NAME = 0xA10C /** Sort.ByArtist */ 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 78a2ff97d..d280a1e49 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -149,7 +149,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}") } 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 0b1bb4824..8b7e9abae 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -46,10 +46,7 @@ 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 @@ -278,16 +275,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 || @@ -325,6 +314,7 @@ class HomeFragment : 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 } } @@ -497,6 +487,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..53fa86faa 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,29 @@ 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)) + + // Add the new playlist tab to old tab configurations + val correctedTabs = oldTabs + Tab.Visible(MusicMode.PLAYLISTS) + sharedPreferences.edit { + putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(correctedTabs)) + 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 081343cdb..d4a6c1c9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -87,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 @@ -127,26 +136,34 @@ constructor( } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (!changes.library) return - val library = musicRepository.library ?: return - logD("Library changed, 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) - _albumsInstructions.put(UpdateInstructions.Diff) - _albumsLists.value = musicSettings.albumSort.albums(library.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 } - } else { - library.artists - }) - _genresInstructions.put(UpdateInstructions.Diff) - _genresList.value = musicSettings.genreSort.genres(library.genres) + val library = musicRepository.library + if (changes.library && library != 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) + _albumsInstructions.put(UpdateInstructions.Diff) + _albumsLists.value = musicSettings.albumSort.albums(library.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 } + } else { + library.artists + }) + _genresInstructions.put(UpdateInstructions.Diff) + _genresList.value = musicSettings.genreSort.genres(library.genres) + } + + val playlists = musicRepository.playlists + if (changes.playlists && playlists != null) { + logD("Refreshing playlists") + _playlistsInstructions.put(UpdateInstructions.Diff) + _playlistsList.value = musicSettings.playlistSort.playlists(playlists) + } } override fun onTabsChanged() { @@ -173,6 +190,7 @@ constructor( MusicMode.ALBUMS -> musicSettings.albumSort MusicMode.ARTISTS -> musicSettings.artistSort MusicMode.GENRES -> musicSettings.genreSort + MusicMode.PLAYLISTS -> musicSettings.playlistSort } /** @@ -204,6 +222,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/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index c6a58f594..29e490dc6 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 @@ -115,7 +115,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..f9b1f9aba 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 @@ -114,7 +114,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..12d21c7c9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -0,0 +1,141 @@ +/* + * 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.Playlist +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 + +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 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.sortName?.thumbString + + // 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_parent_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/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..798d1888d 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. @@ -81,7 +86,7 @@ sealed class Tab(open val mode: MusicMode) { val distinct = tabs.distinctBy { it.mode } var sequence = 0b0100 - var shift = SEQUENCE_LEN * 4 + 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..a1b9db7fe 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 @@ -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/image/ImageGroup.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt index 550f805e3..3f8652a7c 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 @@ -177,6 +174,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..c73af2c73 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt @@ -46,7 +46,8 @@ class CoilModule { songFactory: AlbumCoverFetcher.SongFactory, albumFactory: AlbumCoverFetcher.AlbumFactory, artistFactory: ArtistImageFetcher.Factory, - genreFactory: GenreImageFetcher.Factory + genreFactory: GenreImageFetcher.Factory, + playlistFactory: PlaylistImageFetcher.Factory ) = ImageLoader.Builder(context) .components { @@ -56,6 +57,7 @@ class CoilModule { add(albumFactory) add(artistFactory) add(genreFactory) + add(playlistFactory) } // Use our own crossfade with error drawable support .transitionFactory(ErrorCrossfadeTransitionFactory()) 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..8523ae18b 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 @@ -123,6 +119,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr */ fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image) + /** + * Bind a [Playlist]'s image to this view, also updating the content description. + * + * @param playlist the [Playlist] to bind. + */ + fun bind(playlist: Playlist) = + bindImpl(playlist, R.drawable.ic_playlist_24, R.string.desc_playlist_image) + /** * Internally bind a [Music]'s image to this view. * 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..f2898f526 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 @@ -33,11 +33,7 @@ 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. @@ -74,14 +70,12 @@ private constructor( 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) = @@ -108,7 +102,6 @@ private constructor( return Images.createMosaic(context, results, size) } - /** [Fetcher.Factory] implementation. */ class Factory @Inject constructor(private val extractor: CoverExtractor) : Fetcher.Factory { override fun create(data: Artist, options: Options, imageLoader: ImageLoader) = @@ -133,7 +126,6 @@ private constructor( 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) = @@ -141,6 +133,30 @@ private constructor( } } +/** + * [Fetcher] for [Playlist] images. Use [Factory] for instantiation. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class PlaylistImageFetcher +private constructor( + private val context: Context, + private val extractor: CoverExtractor, + private val size: Size, + private val playlist: Playlist +) : Fetcher { + override suspend fun fetch(): FetchResult? { + val results = playlist.albums.mapAtMostNotNull(4) { album -> extractor.extract(album) } + return Images.createMosaic(context, results, size) + } + + class Factory @Inject constructor(private val extractor: CoverExtractor) : + Fetcher.Factory { + override fun create(data: Playlist, options: Options, imageLoader: ImageLoader) = + PlaylistImageFetcher(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]. 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..c3fc735b8 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -217,6 +217,40 @@ abstract class ListFragment : } } + /** + * 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 genre The [Playlist] to create the menu for. + */ + protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Playlist) { + logD("Launching new genre menu: ${genre.rawName}") + + openMusicMenuImpl(anchor, menuRes) { + when (it.itemId) { + R.id.action_play -> { + // playbackModel.play(genre) + } + R.id.action_shuffle -> { + // playbackModel.shuffle(genre) + } + R.id.action_play_next -> { + // playbackModel.playNext(genre) + // requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_queue_add -> { + // playbackModel.addToQueue(genre) + // requireContext().showToast(R.string.lng_queue_added) + } + else -> { + error("Unexpected menu item selected") + } + } + } + } + private fun openMusicMenuImpl( anchor: View, @MenuRes menuRes: Int, 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..0aed7203c 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt @@ -102,41 +102,37 @@ 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)) } - /** - * 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)) } - /** - * 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)) } - /** - * 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)) } + private fun playlistsInPlace(playlists: MutableList) { + playlists.sortWith(mode.getPlaylistComparator(direction)) + } + /** * The integer representation of this instance. * @@ -200,6 +196,16 @@ data class Sort(val mode: Mode, val direction: Direction) { throw UnsupportedOperationException() } + /** + * Return a [Comparator] that sorts [Playlist]s according to this [Mode]. + * + * @param direction The direction to sort in. + * @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode]. + */ + open fun getPlaylistComparator(direction: Direction): Comparator { + throw UnsupportedOperationException() + } + /** * Sort by the item's name. * @@ -223,12 +229,15 @@ 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.sortName */ object ByAlbum : Mode() { override val intCode: Int @@ -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)) } /** @@ -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)) } /** @@ -555,6 +574,8 @@ data class Sort(val mode: Mode, val direction: Direction) { 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() } } 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..7526ad0c9 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 @@ -249,6 +249,60 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding val DIFF_CALLBACK = object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean = + oldItem.rawName == newItem.rawName && + 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.resolveName(binding.context) + binding.parentInfo.text = + binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size) + } + + 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): Boolean = oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt index 7741136a8..545a5f234 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt @@ -20,9 +20,7 @@ package org.oxycblt.auxio.music import android.os.Build -/** - * Version-aware permission identifier for reading audio files. - */ +/** 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 @@ -32,25 +30,29 @@ val PERMISSION_READ_AUDIO = /** * 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. + * + * @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 { @@ -59,6 +61,7 @@ sealed interface IndexingProgress { /** * Songs are currently being loaded. + * * @param current The current amount of songs loaded. * @param total The projected total amount of songs. */ @@ -67,6 +70,7 @@ sealed interface IndexingProgress { /** * Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted. + * * @author Alexander Capehart (OxygenCobalt) */ class NoAudioPermissionException : Exception() { @@ -75,6 +79,7 @@ class NoAudioPermissionException : Exception() { /** * Thrown when no music was found. + * * @author Alexander Capehart (OxygenCobalt) */ class NoMusicException : Exception() { 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 e4b825201..f0f32a8b2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -370,7 +370,12 @@ interface Genre : MusicParent { * * @author Alexander Capehart (OxygenCobalt) */ -interface Playlist : MusicParent +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 +} /** * A black-box datatype for a variation of music names that is suitable for music-oriented sorting. 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/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 07bbe4214..e6517d781 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -37,8 +37,8 @@ import org.oxycblt.auxio.util.logW /** * Primary manager of music information and loading. * - * Music information is loaded in-memory by this repository using an [IndexingWorker]. - * Changes in music (loading) can be reacted to with [UpdateListener] and [IndexingListener]. + * 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) */ @@ -52,6 +52,7 @@ interface MusicRepository { /** * Add an [UpdateListener] to receive updates from this instance. + * * @param listener The [UpdateListener] to add. */ fun addUpdateListener(listener: UpdateListener) @@ -59,12 +60,14 @@ interface MusicRepository { /** * Remove an [UpdateListener] such that it does not receive any further updates from this * instance. + * * @param listener The [UpdateListener] to remove. */ fun removeUpdateListener(listener: UpdateListener) /** * Add an [IndexingListener] to receive updates from this instance. + * * @param listener The [UpdateListener] to add. */ fun addIndexingListener(listener: IndexingListener) @@ -72,6 +75,7 @@ interface MusicRepository { /** * 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) @@ -79,13 +83,15 @@ interface MusicRepository { /** * 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. + * 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) @@ -93,62 +99,56 @@ interface MusicRepository { /** * 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. - */ + /** A listener for changes to the stored music information. */ interface UpdateListener { /** * Called when a change to the stored music information occurs. + * * @param changes The [Changes] that have occured. */ fun onMusicChanges(changes: Changes) } + /** * Flags indicating which kinds of music information changed. + * * @param library Whether the current [Library] has changed. * @param playlists Whether the current [Playlist]s have changed. */ data class Changes(val library: Boolean, val playlists: Boolean) - /** - * A listener for events in the music loading process. - */ + /** A listener for events in the music loading process. */ interface IndexingListener { - /** - * Called when the music loading state changed. - */ + /** Called when the music loading state changed. */ fun onIndexingStateChanged() } - /** - * A persistent worker that can load music in the background. - */ + /** A persistent worker that can load music in the background. */ interface IndexingWorker { - /** - * A [Context] required to read device storage - */ + /** A [Context] required to read device storage */ val context: Context - /** - * The [CoroutineScope] to perform coroutine music loading work on. - */ + /** 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. + * 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) @@ -301,6 +301,7 @@ constructor( cacheRepository.writeCache(rawSongs) } val newLibrary = libraryJob.await() + // TODO: Make real playlist reading withContext(Dispatchers.Main) { emitComplete(null) emitData(newLibrary, listOf()) 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 c5f532e39..6d6af6d46 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -54,6 +54,8 @@ 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. */ @@ -161,6 +163,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/playlist/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistImpl.kt index dcb80a237..848685ac6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistImpl.kt @@ -30,4 +30,7 @@ class PlaylistImpl(rawPlaylist: RawPlaylist, library: Library, musicSettings: Mu override val rawSortName = null override val sortName = SortName(rawName, musicSettings) override val songs = rawPlaylist.songs.mapNotNull { library.find(it.songUid) } + override val durationMs = songs.sumOf { it.durationMs } + override val albums = + songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key } } 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 0bf724abf..23c7af7b6 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -178,6 +178,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.") } } 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 ce118eee4..c0facb71e 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -148,9 +148,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 Playlist -> TODO("handle this") + 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_parent_actions, item) } } 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 295eab901..99f857793 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -154,6 +154,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_all // TODO: Handle // Null maps to filtering nothing. null -> R.id.option_filter_all } 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/menu/menu_artist_actions.xml b/app/src/main/res/menu/menu_parent_actions.xml similarity index 100% rename from app/src/main/res/menu/menu_artist_actions.xml rename to app/src/main/res/menu/menu_parent_actions.xml 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 41d015a28..af89461bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,6 +76,9 @@ Genre Genres + Playlist + Playlists + Search @@ -307,6 +310,7 @@ Album cover for %s Artist image for %s Genre image for %s + Playlist image for %s From d8b67a8512768e535dc4289e953c689995400add Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 20 Mar 2023 20:29:43 -0600 Subject: [PATCH 07/88] test: fix failure Once again, forgot to update the mock implementations. --- app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt | 2 +- app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) 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 798d1888d..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 @@ -85,7 +85,7 @@ 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 sequence = 0 var shift = MAX_SEQUENCE_IDX * 4 for (tab in distinct) { val bin = 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 adb1fd1d0..07c5166c9 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt @@ -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(value) = throw NotImplementedError() override var albumSongSort: Sort get() = throw NotImplementedError() set(_) = throw NotImplementedError() From f846a08b013f23e0f2600a672ed0130784c463e8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 22 Mar 2023 17:09:33 -0600 Subject: [PATCH 08/88] music: refactor library usage Refactor the disjoint Library and Playlist setup into two new DeviceLibrary and UserLibrary implementations. This makes the API surface a bit less disjoint than prior. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 25 ++-- .../auxio/detail/GenreDetailFragment.kt | 3 +- .../org/oxycblt/auxio/home/HomeViewModel.kt | 22 ++-- .../list/selection/SelectionViewModel.kt | 15 ++- .../java/org/oxycblt/auxio/music/Music.kt | 8 +- .../oxycblt/auxio/music/MusicRepository.kt | 74 +++++++---- .../org/oxycblt/auxio/music/MusicSettings.kt | 6 +- .../org/oxycblt/auxio/music/MusicViewModel.kt | 14 +- .../auxio/music/cache/CacheDatabase.kt | 2 +- .../auxio/music/cache/CacheRepository.kt | 2 +- .../Library.kt => device/DeviceLibrary.kt} | 120 ++++++++++-------- .../auxio/music/device/DeviceModule.kt | 31 +++++ .../DeviceMusicImpl.kt} | 14 +- .../music/{library => device}/RawMusic.kt | 4 +- .../music/{storage => fs}/DirectoryAdapter.kt | 2 +- .../music/{storage/Filesystem.kt => fs/Fs.kt} | 4 +- .../StorageModule.kt => fs/FsModule.kt} | 6 +- .../{storage => fs}/MediaStoreExtractor.kt | 4 +- .../music/{storage => fs}/MusicDirsDialog.kt | 2 +- .../music/{storage => fs}/StorageUtil.kt | 2 +- .../oxycblt/auxio/music/metadata/AudioInfo.kt | 2 +- .../auxio/music/metadata/TagExtractor.kt | 2 +- .../oxycblt/auxio/music/metadata/TagWorker.kt | 4 +- .../auxio/music/system/IndexerService.kt | 11 +- .../{playlist => user}/PlaylistDatabase.kt | 2 +- .../music/{playlist => user}/PlaylistImpl.kt | 13 +- .../music/{playlist => user}/RawPlaylist.kt | 2 +- .../oxycblt/auxio/music/user/UserLibrary.kt | 79 ++++++++++++ .../PlaylistModule.kt => user/UserModule.kt} | 13 +- .../oxycblt/auxio/picker/PickerViewModel.kt | 6 +- .../auxio/playback/PlaybackViewModel.kt | 17 +-- .../playback/persist/PersistenceRepository.kt | 24 ++-- .../auxio/playback/system/PlaybackService.kt | 15 ++- .../oxycblt/auxio/search/SearchViewModel.kt | 26 ++-- app/src/main/res/navigation/nav_main.xml | 2 +- .../java/org/oxycblt/auxio/music/FakeMusic.kt | 5 +- .../auxio/music/FakeMusicRepository.kt | 22 ++-- .../oxycblt/auxio/music/FakeMusicSettings.kt | 2 +- .../oxycblt/auxio/music/MusicViewModelTest.kt | 15 ++- .../FakeDeviceLibrary.kt} | 16 ++- .../music/{library => device}/RawMusicTest.kt | 2 +- 41 files changed, 398 insertions(+), 242 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{library/Library.kt => device/DeviceLibrary.kt} (74%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt rename app/src/main/java/org/oxycblt/auxio/music/{library/MusicImpl.kt => device/DeviceMusicImpl.kt} (98%) rename app/src/main/java/org/oxycblt/auxio/music/{library => device}/RawMusic.kt (98%) rename app/src/main/java/org/oxycblt/auxio/music/{storage => fs}/DirectoryAdapter.kt (98%) rename app/src/main/java/org/oxycblt/auxio/music/{storage/Filesystem.kt => fs/Fs.kt} (99%) rename app/src/main/java/org/oxycblt/auxio/music/{storage/StorageModule.kt => fs/FsModule.kt} (92%) rename app/src/main/java/org/oxycblt/auxio/music/{storage => fs}/MediaStoreExtractor.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{storage => fs}/MusicDirsDialog.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{storage => fs}/StorageUtil.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{playlist => user}/PlaylistDatabase.kt (96%) rename app/src/main/java/org/oxycblt/auxio/music/{playlist => user}/PlaylistImpl.kt (79%) rename app/src/main/java/org/oxycblt/auxio/music/{playlist => user}/RawPlaylist.kt (97%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt rename app/src/main/java/org/oxycblt/auxio/music/{playlist/PlaylistModule.kt => user/UserModule.kt} (83%) rename app/src/test/java/org/oxycblt/auxio/music/{library/FakeLibrary.kt => device/FakeDeviceLibrary.kt} (77%) rename app/src/test/java/org/oxycblt/auxio/music/{library => device}/RawMusicTest.kt (99%) 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 050b5de16..f2527a752 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -159,8 +159,8 @@ constructor( } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (!changes.library) return - val library = musicRepository.library ?: return + if (!changes.deviceLibrary) return + val deviceLibrary = musicRepository.deviceLibrary ?: return // 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 @@ -168,25 +168,25 @@ constructor( val song = currentSong.value if (song != null) { - _currentSong.value = library.sanitize(song)?.also(::refreshAudioInfo) + _currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo) logD("Updated song to ${currentSong.value}") } val album = currentAlbum.value if (album != null) { - _currentAlbum.value = library.sanitize(album)?.also(::refreshAlbumList) + _currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList) logD("Updated genre to ${currentAlbum.value}") } val artist = currentArtist.value if (artist != null) { - _currentArtist.value = library.sanitize(artist)?.also(::refreshArtistList) + _currentArtist.value = deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList) logD("Updated genre to ${currentArtist.value}") } val genre = currentGenre.value if (genre != null) { - _currentGenre.value = library.sanitize(genre)?.also(::refreshGenreList) + _currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList) logD("Updated genre to ${currentGenre.value}") } } @@ -203,7 +203,7 @@ constructor( return } logD("Opening Song [uid: $uid]") - _currentSong.value = requireMusic(uid)?.also(::refreshAudioInfo) + _currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo) } /** @@ -218,7 +218,8 @@ constructor( return } logD("Opening Album [uid: $uid]") - _currentAlbum.value = requireMusic(uid)?.also(::refreshAlbumList) + _currentAlbum.value = + musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList) } /** @@ -233,7 +234,8 @@ constructor( return } logD("Opening Artist [uid: $uid]") - _currentArtist.value = requireMusic(uid)?.also(::refreshArtistList) + _currentArtist.value = + musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList) } /** @@ -248,11 +250,10 @@ constructor( return } 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) - private fun refreshAudioInfo(song: Song) { // Clear any previous job in order to avoid stale data from appearing in the UI. currentSongJob?.cancel() 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 d280a1e49..f15ca89ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -228,8 +228,7 @@ class GenreDetailFragment : is Genre -> { navModel.exploreNavigationItem.consume() } - is Playlist -> TODO("handle this") - null -> {} + else -> {} } } 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 d4a6c1c9c..bc2baefe9 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -136,33 +136,33 @@ constructor( } override fun onMusicChanges(changes: MusicRepository.Changes) { - val library = musicRepository.library - if (changes.library && library != null) { + val deviceLibrary = musicRepository.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 playlists = musicRepository.playlists - if (changes.playlists && playlists != null) { + val userLibrary = musicRepository.userLibrary + if (changes.userLibrary && userLibrary != null) { logD("Refreshing playlists") _playlistsInstructions.put(UpdateInstructions.Diff) - _playlistsList.value = musicSettings.playlistSort.playlists(playlists) + _playlistsList.value = musicSettings.playlistSort.playlists(userLibrary.playlists) } } @@ -175,7 +175,7 @@ constructor( override fun onHideCollaboratorsChanged() { // Changes in the hide collaborator setting will change the artist contents // of the library, consider it a library update. - onMusicChanges(MusicRepository.Changes(library = true, playlists = false)) + onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) } /** 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 6104ebee1..42fea7d41 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 @@ -43,18 +43,19 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (!changes.library) return - val library = musicRepository.library ?: return + 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 Playlist -> TODO("handle this") + 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) } } } 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 f0f32a8b2..a764ef8ab 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -30,11 +30,11 @@ import kotlin.math.max import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.music.fs.MimeType +import org.oxycblt.auxio.music.fs.Path 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.util.concatLocalized import org.oxycblt.auxio.util.toUuidOrNull @@ -139,10 +139,10 @@ sealed interface Music : Item { object TypeConverters { /** @see [Music.UID.toString] */ - @TypeConverter fun fromMusicUID(uid: Music.UID?) = uid?.toString() + @TypeConverter fun fromMusicUID(uid: UID?) = uid?.toString() /** @see [Music.UID.fromString] */ - @TypeConverter fun toMusicUid(string: String?) = string?.let(Music.UID::fromString) + @TypeConverter fun toMusicUid(string: String?) = string?.let(UID::fromString) } companion object { 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 e6517d781..6295dda5b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -26,10 +26,11 @@ import javax.inject.Inject import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import org.oxycblt.auxio.music.cache.CacheRepository -import org.oxycblt.auxio.music.library.Library -import org.oxycblt.auxio.music.library.RawSong +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.storage.MediaStoreExtractor +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 @@ -43,10 +44,10 @@ import org.oxycblt.auxio.util.logW * @author Alexander Capehart (OxygenCobalt) */ interface MusicRepository { - /** The current immutable music library loaded from the file-system. */ - val library: Library? - /** The current mutable user-defined playlists loaded from the file-system. */ - val playlists: List? + /** 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? @@ -96,6 +97,16 @@ interface MusicRepository { */ 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? + /** * Request that a music loading operation is started by the current [IndexingWorker]. Does * nothing if one is not available. @@ -118,7 +129,7 @@ interface MusicRepository { /** * Called when a change to the stored music information occurs. * - * @param changes The [Changes] that have occured. + * @param changes The [Changes] that have occurred. */ fun onMusicChanges(changes: Changes) } @@ -126,10 +137,10 @@ interface MusicRepository { /** * Flags indicating which kinds of music information changed. * - * @param library Whether the current [Library] has changed. - * @param playlists Whether the current [Playlist]s have changed. + * @param deviceLibrary Whether the current [DeviceLibrary] has changed. + * @param userLibrary Whether the current [Playlist]s have changed. */ - data class Changes(val library: Boolean, val playlists: Boolean) + data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean) /** A listener for events in the music loading process. */ interface IndexingListener { @@ -158,17 +169,18 @@ interface MusicRepository { class MusicRepositoryImpl @Inject constructor( - private val musicSettings: MusicSettings, private val cacheRepository: CacheRepository, private val mediaStoreExtractor: MediaStoreExtractor, - private val tagExtractor: TagExtractor + private val tagExtractor: TagExtractor, + private val deviceLibraryProvider: DeviceLibrary.Provider, + private val userLibraryProvider: UserLibrary.Provider ) : MusicRepository { private val updateListeners = mutableListOf() private val indexingListeners = mutableListOf() private var indexingWorker: MusicRepository.IndexingWorker? = null - override var library: Library? = null - override var playlists: List? = null + override var deviceLibrary: DeviceLibrary? = null + override var userLibrary: UserLibrary? = null private var previousCompletedState: IndexingState.Completed? = null private var currentIndexingState: IndexingState? = null override val indexingState: IndexingState? @@ -216,6 +228,10 @@ constructor( currentIndexingState = null } + override fun find(uid: Music.UID) = + (deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) } + ?: userLibrary?.findPlaylist(uid)) + override fun requestIndex(withCache: Boolean) { indexingWorker?.requestIndex(withCache) } @@ -295,16 +311,20 @@ constructor( // parallel. logD("Discovered ${rawSongs.size} songs, starting finalization") emitLoading(IndexingProgress.Indeterminate) - val libraryJob = - worker.scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) } + val deviceLibraryChannel = Channel() + val deviceLibraryJob = + worker.scope.async(Dispatchers.Main) { + deviceLibraryProvider.create(rawSongs).also { deviceLibraryChannel.send(it) } + } + val userLibraryJob = worker.scope.async { userLibraryProvider.read(deviceLibraryChannel) } if (cache == null || cache.invalidated) { cacheRepository.writeCache(rawSongs) } - val newLibrary = libraryJob.await() - // TODO: Make real playlist reading + val deviceLibrary = deviceLibraryJob.await() + val userLibrary = userLibraryJob.await() withContext(Dispatchers.Main) { emitComplete(null) - emitData(newLibrary, listOf()) + emitData(deviceLibrary, userLibrary) } } @@ -330,14 +350,14 @@ constructor( } @Synchronized - private fun emitData(library: Library, playlists: List) { - val libraryChanged = this.library != library - val playlistsChanged = this.playlists != playlists - if (!libraryChanged && !playlistsChanged) return + private fun emitData(deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) { + val deviceLibraryChanged = this.deviceLibrary != deviceLibrary + val userLibraryChanged = this.userLibrary != userLibrary + if (!deviceLibraryChanged && !userLibraryChanged) return - this.library = library - this.playlists = playlists - val changes = MusicRepository.Changes(libraryChanged, playlistsChanged) + 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 6d6af6d46..94d7e9968 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 @@ -62,7 +62,7 @@ interface MusicSettings : Settings { var artistSongSort: Sort /** The [Sort] mode used in an [Genre]'s [Song] list. */ var genreSongSort: Sort - + /** The [] */ interface Listener { /** Called when a setting controlling how music is loaded has changed. */ fun onIndexingSettingChanged() {} 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 6c4ab3680..40746dd9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -53,15 +53,15 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (!changes.library) return - val library = musicRepository.library ?: return + if (!changes.deviceLibrary) return + val deviceLibrary = musicRepository.deviceLibrary ?: return _statistics.value = Statistics( - library.songs.size, - library.albums.size, - library.artists.size, - library.genres.size, - library.songs.sumOf { it.durationMs }) + deviceLibrary.songs.size, + deviceLibrary.albums.size, + deviceLibrary.artists.size, + deviceLibrary.genres.size, + deviceLibrary.songs.sumOf { it.durationMs }) } override fun onIndexingStateChanged() { 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 325736e85..86e9e4de4 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,7 +27,7 @@ import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.TypeConverter import androidx.room.TypeConverters -import org.oxycblt.auxio.music.library.RawSong +import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped 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 8cfa1f28d..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.library.RawSong +import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.util.* /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt similarity index 74% rename from app/src/main/java/org/oxycblt/auxio/music/library/Library.kt rename to app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 36a5ef7be..a19aec5b4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/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,19 +16,20 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.library +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 @@ -36,40 +37,23 @@ import org.oxycblt.auxio.util.logD * * @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 Provider { + /** + * 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 DeviceLibraryProviderImpl @Inject constructor(private val musicSettings: MusicSettings) : + DeviceLibrary.Provider { + 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( diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt new file mode 100644 index 000000000..3bb3de657 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 Auxio Project + * 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 + * 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.device + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DeviceModule { + @Binds + fun deviceLibraryProvider(providerImpl: DeviceLibraryProviderImpl): DeviceLibrary.Provider +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/MusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/library/MusicImpl.kt rename to app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 975fc7933..f81644d10 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/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,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.library +package org.oxycblt.auxio.music.device import android.content.Context import androidx.annotation.VisibleForTesting @@ -24,15 +24,15 @@ 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.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.metadata.Date import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.ReleaseType 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 @@ -467,7 +467,7 @@ class GenreImpl( * * @return This instance upcasted to [Genre]. */ - fun finalize(): Music { + fun finalize(): Genre { check(songs.isNotEmpty()) { "Malformed genre: Empty" } return this } diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt rename to app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index e8acbad7e..86b8df817 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -16,12 +16,12 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.library +package org.oxycblt.auxio.music.device import java.util.UUID import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.fs.Directory import org.oxycblt.auxio.music.metadata.* -import org.oxycblt.auxio.music.storage.Directory /** * Raw information about a [SongImpl] obtained from the filesystem/Extractor instances. 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 99% 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 5ea3a6cbf..f97318d5c 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,7 +31,7 @@ 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.library.RawSong +import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.parseId3v2PositionField import org.oxycblt.auxio.music.metadata.transformPositionField 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/AudioInfo.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt index b31b5f63f..5f801a1e5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt @@ -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 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 d6e66c094..60586d9ee 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 @@ -22,7 +22,7 @@ import com.google.android.exoplayer2.MetadataRetriever import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield -import org.oxycblt.auxio.music.library.RawSong +import org.oxycblt.auxio.music.device.RawSong /** * The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the 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 62f717d9f..04ca06409 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 @@ -25,8 +25,8 @@ import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.TrackGroupArray import java.util.concurrent.Future import javax.inject.Inject -import org.oxycblt.auxio.music.library.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.util.logD import org.oxycblt.auxio.util.logW 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 a12e252fb..72444399c 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 @@ -34,7 +34,7 @@ import javax.inject.Inject import kotlinx.coroutines.* import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.storage.contentResolverSafe +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 @@ -131,8 +131,8 @@ class IndexerService : override val scope = indexScope override fun onMusicChanges(changes: MusicRepository.Changes) { - if (!changes.library) return - val library = musicRepository.library ?: return + if (!changes.deviceLibrary) return + val deviceLibrary = musicRepository.deviceLibrary ?: return // Wipe possibly-invalidated outdated covers imageLoader.memoryCache?.clear() // Clear invalid models from PlaybackStateManager. This is not connected @@ -141,10 +141,11 @@ class IndexerService : playbackManager.toSavedState()?.let { savedState -> playbackManager.applySavedState( PlaybackStateManager.SavedState( - parent = savedState.parent?.let(library::sanitize), + parent = + savedState.parent?.let { musicRepository.find(it.uid) as? MusicParent }, queueState = savedState.queueState.remap { song -> - library.sanitize(requireNotNull(song)) + deviceLibrary.findSong(requireNotNull(song).uid) }, positionMs = savedState.positionMs, repeatMode = savedState.repeatMode), diff --git a/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistDatabase.kt rename to app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt index 652f0be74..3377b172a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.playlist +package org.oxycblt.auxio.music.user import androidx.room.* import org.oxycblt.auxio.music.Music diff --git a/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt similarity index 79% rename from app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistImpl.kt rename to app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt index 848685ac6..e5432e1d8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -16,20 +16,23 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.playlist +package org.oxycblt.auxio.music.user import android.content.Context import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.device.DeviceLibrary -class PlaylistImpl(rawPlaylist: RawPlaylist, library: Library, musicSettings: MusicSettings) : - Playlist { +class PlaylistImpl( + rawPlaylist: RawPlaylist, + deviceLibrary: DeviceLibrary, + musicSettings: MusicSettings +) : Playlist { override val uid = rawPlaylist.playlistInfo.playlistUid override val rawName = rawPlaylist.playlistInfo.name override fun resolveName(context: Context) = rawName override val rawSortName = null override val sortName = SortName(rawName, musicSettings) - override val songs = rawPlaylist.songs.mapNotNull { library.find(it.songUid) } + override val songs = rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) } override val durationMs = songs.sumOf { it.durationMs } override val albums = songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key } diff --git a/app/src/main/java/org/oxycblt/auxio/music/playlist/RawPlaylist.kt b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/music/playlist/RawPlaylist.kt rename to app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt index 58f232f47..6f56be360 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/playlist/RawPlaylist.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.playlist +package org.oxycblt.auxio.music.user import androidx.room.* import org.oxycblt.auxio.music.Music 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..5e1b86860 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -0,0 +1,79 @@ +/* + * 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.Music +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Playlist +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? + + /** Constructs a [UserLibrary] implementation in an asynchronous manner. */ + interface Provider { + /** + * Create a new [UserLibrary]. + * + * @param deviceLibrary Asynchronously populated [DeviceLibrary] that can be obtained later. + * This allows database information to be read before the actual instance is constructed. + */ + suspend fun read(deviceLibrary: Channel): UserLibrary + } +} + +class UserLibraryProviderImpl +@Inject +constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : + UserLibrary.Provider { + override suspend fun read(deviceLibrary: Channel): UserLibrary = + UserLibraryImpl(playlistDao, deviceLibrary.receive(), musicSettings) +} + +private class UserLibraryImpl( + private val playlistDao: PlaylistDao, + private val deviceLibrary: DeviceLibrary, + private val musicSettings: MusicSettings +) : UserLibrary { + private val playlistMap = mutableMapOf() + override val playlists: List + get() = playlistMap.values.toList() + + override fun findPlaylist(uid: Music.UID) = playlistMap[uid] +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistModule.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt similarity index 83% rename from app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistModule.kt rename to app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt index 45da01dc7..9c92c0ca5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/playlist/PlaylistModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * PlaylistModule.kt is part of Auxio. + * 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 @@ -16,10 +16,11 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.playlist +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 @@ -28,7 +29,13 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) -class PlaylistModule { +interface UserModule { + @Binds fun userLibaryProvider(provider: UserLibraryProviderImpl): UserLibrary.Provider +} + +@Module +@InstallIn(SingletonComponent::class) +class UserRoomModule { @Provides fun playlistDao(database: PlaylistDatabase) = database.playlistDao() @Provides diff --git a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt index cda23118c..0ddb37953 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.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.util.unlikelyToBeNull /** * a [ViewModel] that manages the current music picker state. Make it so that the dialogs just @@ -60,7 +59,7 @@ class PickerViewModel @Inject constructor(private val musicRepository: MusicRepo } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (changes.library && musicRepository.library != null) { + if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { refreshChoices() } } @@ -71,8 +70,7 @@ class PickerViewModel @Inject constructor(private val musicRepository: MusicRepo * @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) + _currentItem.value = musicRepository.find(uid) refreshChoices() } 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 23c7af7b6..f5f177005 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -282,7 +282,7 @@ constructor( check(song == null || parent == null || parent.songs.contains(song)) { "Song to play not in parent" } - val library = musicRepository.library ?: return + val deviceLibrary = musicRepository.deviceLibrary ?: return val sort = when (parent) { is Genre -> musicSettings.genreSongSort @@ -291,7 +291,7 @@ constructor( is Playlist -> TODO("handle this") null -> musicSettings.songSort } - val queue = sort.songs(parent?.songs ?: library.songs) + val queue = sort.songs(parent?.songs ?: deviceLibrary.songs) playbackManager.play(song, parent, queue, shuffled) } @@ -469,14 +469,11 @@ 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) } 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 38cbba829..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.library.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/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 5cc4c89fb..a03f231fd 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 @@ -299,7 +299,7 @@ class PlaybackService : } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (changes.library && musicRepository.library != null) { + if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { // We now have a library, see if we have anything we need to do. playbackManager.requestAction(this) } @@ -328,8 +328,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 @@ -339,22 +339,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/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 99f857793..68a0b3b7d 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -33,7 +33,7 @@ import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD @@ -73,7 +73,7 @@ constructor( } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (changes.library && musicRepository.library != null) { + if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { search(lastQuery) } } @@ -89,8 +89,8 @@ constructor( currentSearchJob?.cancel() lastQuery = query - val library = musicRepository.library - if (query.isNullOrEmpty() || library == null) { + val deviceLibrary = musicRepository.deviceLibrary + if (query.isNullOrEmpty() || deviceLibrary == null) { logD("Search query is not applicable.") _searchResults.value = listOf() return @@ -101,23 +101,27 @@ 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, query).also { yield() } } } - private suspend fun searchImpl(library: Library, query: String): List { + private suspend fun searchImpl(deviceLibrary: DeviceLibrary, 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) } 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) } val results = searchEngine.search(items, query) diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 42ea52268..69a13a74c 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -143,7 +143,7 @@ tools:layout="@layout/dialog_pre_amp" /> ? + override val userLibrary: UserLibrary? get() = throw NotImplementedError() - set(_) { - throw NotImplementedError() - } override fun addUpdateListener(listener: MusicRepository.UpdateListener) { throw NotImplementedError() @@ -62,6 +54,10 @@ open class FakeMusicRepository : MusicRepository { throw NotImplementedError() } + override fun find(uid: Music.UID): Music? { + throw NotImplementedError() + } + override fun requestIndex(withCache: Boolean) { 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 07c5166c9..83b6f7e2d 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() 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 92b2534b0..b1540283e 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt @@ -21,8 +21,8 @@ package org.oxycblt.auxio.music import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import org.oxycblt.auxio.music.library.FakeLibrary -import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.device.FakeDeviceLibrary import org.oxycblt.auxio.util.forceClear class MusicViewModelTest { @@ -49,7 +49,7 @@ class MusicViewModelTest { val musicRepository = TestMusicRepository() val musicViewModel = MusicViewModel(musicRepository) assertEquals(null, musicViewModel.statistics.value) - musicRepository.library = TestLibrary() + musicRepository.deviceLibrary = TestDeviceLibrary() assertEquals( MusicViewModel.Statistics( 2, @@ -71,11 +71,11 @@ class MusicViewModelTest { } private class TestMusicRepository : FakeMusicRepository() { - override var library: Library? = null + override var deviceLibrary: DeviceLibrary? = null set(value) { field = value updateListener?.onMusicChanges( - MusicRepository.Changes(library = true, playlists = false)) + MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) } override var indexingState: IndexingState? = null set(value) { @@ -88,7 +88,8 @@ class MusicViewModelTest { val requests = mutableListOf() override fun addUpdateListener(listener: MusicRepository.UpdateListener) { - listener.onMusicChanges(MusicRepository.Changes(library = true, playlists = false)) + listener.onMusicChanges( + MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) this.updateListener = listener } @@ -110,7 +111,7 @@ class MusicViewModelTest { } } - 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/library/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/library/FakeLibrary.kt rename to app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt index 7230e5e76..93cbaa62c 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/library/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.library +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/library/RawMusicTest.kt b/app/src/test/java/org/oxycblt/auxio/music/device/RawMusicTest.kt similarity index 99% rename from app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt rename to app/src/test/java/org/oxycblt/auxio/music/device/RawMusicTest.kt index a4fafb324..99d5a148b 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/device/RawMusicTest.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.library +package org.oxycblt.auxio.music.device import java.util.* import org.junit.Assert.assertEquals From f3a2d94086bf2afa8aec27f43d3d103f3391c7ff Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 22 Mar 2023 17:21:28 -0600 Subject: [PATCH 09/88] music: add playlist song sorting Add the internal sort modes for playlist songs. Theoretically this might be better as playlist-specific, but it's harder to integrate that currently. --- .../org/oxycblt/auxio/music/MusicSettings.kt | 16 ++++++++++++++-- .../oxycblt/auxio/playback/PlaybackViewModel.kt | 10 ++++++---- app/src/main/res/values/settings.xml | 1 + 3 files changed, 21 insertions(+), 6 deletions(-) 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 94d7e9968..23ce121ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -60,9 +60,11 @@ interface MusicSettings : Settings { 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 - /** The [] */ + /** The [Sort] mode used in a [Playlist]'s [Song] list, or null if sorting by original ordering. */ + var playlistSongSort: Sort? + interface Listener { /** Called when a setting controlling how music is loaded has changed. */ fun onIndexingSettingChanged() {} @@ -222,6 +224,16 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context } } + override var playlistSongSort: Sort? + get() = Sort.fromIntCode(sharedPreferences.getInt( + getString(R.string.set_key_playlist_songs_sort), Int.MIN_VALUE)) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.lbl_playlist), value?.intCode ?: -1) + apply() + } + } + override fun onSettingChanged(key: String, listener: MusicSettings.Listener) { // TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads" // (just need to manipulate data) 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 f5f177005..f000a02b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.playback import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.room.util.query import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job @@ -288,10 +289,11 @@ constructor( is Genre -> musicSettings.genreSongSort is Artist -> musicSettings.artistSongSort is Album -> musicSettings.albumSongSort - is Playlist -> TODO("handle this") + is Playlist -> musicSettings.playlistSongSort null -> musicSettings.songSort } - val queue = sort.songs(parent?.songs ?: deviceLibrary.songs) + val songs = parent?.songs ?: deviceLibrary.songs + val queue = sort?.songs(songs) ?: songs playbackManager.play(song, parent, queue, shuffled) } @@ -489,11 +491,11 @@ constructor( private fun selectionToSongs(selection: List): List { return selection.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 Song -> listOf(it) - is Playlist -> TODO("handle this") + is Playlist -> musicSettings.playlistSongSort?.songs(it.songs) ?: it.songs } } } diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index faab5d5cd..e9e9d5f2b 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -52,6 +52,7 @@ auxio_album_sort auxio_artist_sort auxio_genre_sort + auxio_playlist_sort @string/set_theme_auto From 4068c3e009b220a5f31a3dfbc25723e26b3cadc2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 22 Mar 2023 19:58:06 -0600 Subject: [PATCH 10/88] detail: add playlist view Add a detail view for playlists. This is most equivelent to the genre detail view right now, but will be differentiated eventually. --- .../java/org/oxycblt/auxio/IntegerTable.kt | 20 +- .../auxio/detail/AlbumDetailFragment.kt | 4 + .../auxio/detail/ArtistDetailFragment.kt | 10 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 73 +++++- .../auxio/detail/GenreDetailFragment.kt | 10 +- .../auxio/detail/PlaylistDetailFragment.kt | 242 ++++++++++++++++++ .../detail/header/AlbumDetailHeaderAdapter.kt | 1 + .../header/ArtistDetailHeaderAdapter.kt | 1 + .../detail/header/GenreDetailHeaderAdapter.kt | 6 +- .../header/PlaylistDetailHeaderAdapter.kt | 85 ++++++ .../detail/list/GenreDetailListAdapter.kt | 2 +- .../detail/list/PlaylistDetailListAdapter.kt | 78 ++++++ .../org/oxycblt/auxio/home/HomeFragment.kt | 3 +- .../org/oxycblt/auxio/list/ListFragment.kt | 2 - .../main/java/org/oxycblt/auxio/list/Sort.kt | 58 +++-- .../org/oxycblt/auxio/music/MusicSettings.kt | 14 +- .../oxycblt/auxio/music/user/UserLibrary.kt | 13 + .../auxio/playback/PlaybackViewModel.kt | 1 - .../oxycblt/auxio/search/SearchFragment.kt | 3 +- ...tist_detail.xml => menu_parent_detail.xml} | 0 app/src/main/res/menu/menu_playlist_sort.xml | 33 +++ app/src/main/res/navigation/nav_explore.xml | 21 ++ app/src/main/res/values/strings.xml | 1 + .../oxycblt/auxio/music/FakeMusicSettings.kt | 3 + 24 files changed, 628 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt rename app/src/main/res/menu/{menu_genre_artist_detail.xml => menu_parent_detail.xml} (100%) create mode 100644 app/src/main/res/menu/menu_playlist_sort.xml diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 47a4d9781..3e9a639c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -77,24 +77,26 @@ object IntegerTable { const val MUSIC_MODE_GENRES = 0xA108 /** MusicMode.PLAYLISTS */ const val MUSIC_MODE_PLAYLISTS = 0xA107 - /** Sort.ByName */ + /** 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/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 12f7098cf..e253f0752 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -159,8 +159,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 +173,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 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..ed3ff8f13 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -87,7 +87,7 @@ class ArtistDetailFragment : // --- UI SETUP --- binding.detailToolbar.apply { - inflateMenu(R.menu.menu_genre_artist_detail) + inflateMenu(R.menu.menu_parent_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@ArtistDetailFragment) } @@ -97,7 +97,7 @@ class ArtistDetailFragment : // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. detailModel.setArtistUid(args.artistUid) - collectImmediately(detailModel.currentArtist, ::updateItem) + collectImmediately(detailModel.currentArtist, ::updateArtist) collectImmediately(detailModel.artistList, ::updateList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) @@ -171,8 +171,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 +186,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,7 +198,7 @@ class ArtistDetailFragment : } } - private fun updateItem(artist: Artist?) { + private fun updateArtist(artist: Artist?) { if (artist == null) { // Artist we were showing no longer exists. findNavController().navigateUp() 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 f2527a752..8369fcf67 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -143,6 +143,31 @@ 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 + + /** The current [Sort] used for [Song]s in [playlistList]. */ + var playlistSongSort: Sort + get() = musicSettings.playlistSongSort + set(value) { + logD(value) + musicSettings.playlistSongSort = value + logD(musicSettings.playlistSongSort) + // Refresh the playlist list to reflect the new sort. + currentPlaylist.value?.let { refreshPlaylistList(it, true) } + } + /** * The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently * shown item. @@ -161,6 +186,7 @@ constructor( override fun onMusicChanges(changes: MusicRepository.Changes) { if (!changes.deviceLibrary) return val deviceLibrary = musicRepository.deviceLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return // 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 @@ -175,13 +201,13 @@ constructor( val album = currentAlbum.value if (album != null) { _currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList) - logD("Updated genre to ${currentAlbum.value}") + logD("Updated album to ${currentAlbum.value}") } val artist = currentArtist.value if (artist != null) { _currentArtist.value = deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList) - logD("Updated genre to ${currentArtist.value}") + logD("Updated artist to ${currentArtist.value}") } val genre = currentGenre.value @@ -189,6 +215,12 @@ constructor( _currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList) logD("Updated genre to ${currentGenre.value}") } + + val playlist = currentPlaylist.value + if (playlist != null) { + _currentPlaylist.value = + userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList) + } } /** @@ -254,6 +286,22 @@ constructor( musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) } + /** + * 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 setPlaylistUid(uid: Music.UID) { + if (_currentPlaylist.value?.uid == uid) { + // Nothing to do. + return + } + logD("Opening Playlist [uid: $uid]") + _currentPlaylist.value = + musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList) + } + private fun refreshAudioInfo(song: Song) { // Clear any previous job in order to avoid stale data from appearing in the UI. currentSongJob?.cancel() @@ -267,7 +315,7 @@ constructor( } 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 instructions = @@ -299,7 +347,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) @@ -348,7 +396,7 @@ 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)) @@ -366,6 +414,21 @@ constructor( _genreList.value = list } + private fun refreshPlaylistList(playlist: Playlist, replace: Boolean = false) { + logD("Refreshing playlist list") + val list = mutableListOf() + list.add(SortHeader(R.string.lbl_songs)) + val instructions = + if (replace) { + UpdateInstructions.Replace(list.size) + } else { + UpdateInstructions.Diff + } + list.addAll(playlistSongSort.songs(playlist.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 f15ca89ac..ebcc60a02 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -81,7 +81,7 @@ class GenreDetailFragment : // --- UI SETUP --- binding.detailToolbar.apply { - inflateMenu(R.menu.menu_genre_artist_detail) + inflateMenu(R.menu.menu_parent_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@GenreDetailFragment) } @@ -91,7 +91,7 @@ class GenreDetailFragment : // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. detailModel.setGenreUid(args.genreUid) - collectImmediately(detailModel.currentGenre, ::updateItem) + collectImmediately(detailModel.currentGenre, ::updatePlaylist) collectImmediately(detailModel.genreList, ::updateList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) @@ -165,8 +165,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 @@ -177,8 +179,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 @@ -186,7 +190,7 @@ class GenreDetailFragment : } } - private fun updateItem(genre: Genre?) { + private fun updatePlaylist(genre: Genre?) { if (genre == null) { // Genre we were showing no longer exists. findNavController().navigateUp() 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..e0b145b04 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -0,0 +1,242 @@ +/* + * 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.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.ConcatAdapter +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.DetailListAdapter +import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter +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.* +import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.NavigationViewModel +import org.oxycblt.auxio.util.* + +/** + * A [ListFragment] that shows information for a particular [Playlist]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class PlaylistDetailFragment : + ListFragment(), + DetailHeaderAdapter.Listener, + DetailListAdapter.Listener { + private val detailModel: DetailViewModel by activityViewModels() + override val navModel: NavigationViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel 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) + + 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.detailToolbar.apply { + inflateMenu(R.menu.menu_parent_detail) + setNavigationOnClickListener { findNavController().navigateUp() } + setOnMenuItemClickListener(this@PlaylistDetailFragment) + } + + binding.detailRecycler.adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) + + // --- VIEWMODEL SETUP --- + // DetailViewModel handles most initialization from the navigation argument. + detailModel.setPlaylistUid(args.playlistUid) + collectImmediately(detailModel.currentPlaylist, ::updatePlaylist) + collectImmediately(detailModel.playlistList, ::updateList) + collectImmediately( + playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) + collect(navModel.exploreNavigationItem.flow, ::handleNavigation) + collectImmediately(selectionModel.selected, ::updateSelection) + } + + override fun onDestroyBinding(binding: FragmentDetailBinding) { + super.onDestroyBinding(binding) + binding.detailToolbar.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. + detailModel.playlistInstructions.consume() + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + if (super.onMenuItemClick(item)) { + return true + } + + // TODO: Handle + 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 + } + else -> false + } + } + + override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) { + // TODO: Handle + } + + override fun onRealClick(item: Song) { + // TODO: Handle + } + + override fun onOpenMenu(item: Song, anchor: View) { + openMusicMenu(anchor, R.menu.menu_song_actions, item) + } + + override fun onPlay() { + // TODO: Handle + // playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value)) + } + + override fun onShuffle() { + // playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value)) + } + + override fun onOpenSortMenu(anchor: View) { + openMenu(anchor, R.menu.menu_playlist_sort) { + // Select the corresponding sort mode option + val sort = detailModel.playlistSongSort + 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 + Sort.Direction.DESCENDING -> R.id.option_sort_dec + } + unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true + // If there is no sort specified, disable the ascending/descending options, as + // they make no sense. We still do want to indicate the state however, in the case + // that the user wants to switch to a sort mode where they do make sense. + if (sort.mode is Sort.Mode.ByNone) { + menu.findItem(R.id.option_sort_dec).isEnabled = false + menu.findItem(R.id.option_sort_asc).isEnabled = false + } + + setOnMenuItemClickListener { item -> + item.isChecked = !item.isChecked + detailModel.playlistSongSort = + 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 + } + } + } + + private fun updatePlaylist(playlist: Playlist?) { + if (playlist == null) { + // Playlist we were showing no longer exists. + findNavController().navigateUp() + return + } + requireBinding().detailToolbar.title = playlist.resolveName(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 updateSelection(selected: List) { + playlistListAdapter.setSelected(selected.toSet()) + requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) + } +} 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..c7747ce2c 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) : 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..0e0e0a691 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) : diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt index 23e4ca855..99a816391 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt @@ -24,7 +24,6 @@ import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding -import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural @@ -33,6 +32,7 @@ import org.oxycblt.auxio.util.inflater /** * A [DetailHeaderAdapter] that shows [Genre] information. * + * @param listener [DetailHeaderAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ class GenreDetailHeaderAdapter(private val listener: Listener) : @@ -57,7 +57,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) : * Bind new data to this instance. * * @param genre The new [Genre] to bind. - * @param listener A [DetailListAdapter.Listener] to bind interactions to. + * @param listener A [DetailHeaderAdapter.Listener] to bind interactions to. */ fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) { binding.detailCover.bind(genre) @@ -65,7 +65,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) : binding.detailName.text = genre.resolveName(binding.context) // Nothing about a genre is applicable to the sub-head text. binding.detailSubhead.isVisible = false - // The song count of the genre maps to the info text. + // The song and artist count of the genre maps to the info text. binding.detailInfo.text = binding.context.getString( R.string.fmt_two, diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt new file mode 100644 index 000000000..db6464f93 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistDetailHeaderAdapter.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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.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() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + PlaylistDetailHeaderViewHolder.from(parent) + + override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) = + holder.bind(parent, listener) +} + +/** + * 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 listener A [DetailHeaderAdapter.Listener] to bind interactions to. + */ + fun bind(playlist: Playlist, listener: DetailHeaderAdapter.Listener) { + binding.detailCover.bind(playlist) + binding.detailType.text = binding.context.getString(R.string.lbl_playlist) + binding.detailName.text = playlist.resolveName(binding.context) + // Nothing about a playlist is applicable to the sub-head text. + binding.detailSubhead.isVisible = false + // The song count of the playlist maps to the info text. + binding.detailInfo.text = + binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size) + binding.detailPlayButton.setOnClickListener { listener.onPlay() } + binding.detailShuffleButton.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/GenreDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt index 67ebe3781..e4a33c80b 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) 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..a6c695ac2 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -0,0 +1,78 @@ +/* + * 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.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback +import org.oxycblt.auxio.list.recycler.SongViewHolder +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song + +/** + * A [DetailListAdapter] implementing the header and sub-items 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) { + override fun getItemViewType(position: Int) = + when (getItem(position)) { + // Support generic song items. + is Song -> SongViewHolder.VIEW_TYPE + else -> super.getItemViewType(position) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + if (viewType == SongViewHolder.VIEW_TYPE) { + SongViewHolder.from(parent) + } else { + super.onCreateViewHolder(parent, viewType) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + val item = getItem(position) + if (item is Song) { + (holder as SongViewHolder).bind(item, listener) + } + } + + override fun isItemFullWidth(position: Int): Boolean { + if (super.isItemFullWidth(position)) { + return true + } + // Playlist headers should be full-width in all configurations + return getItem(position) is Playlist + } + + companion object { + val DIFF_CALLBACK = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: Item, newItem: Item) = + when { + oldItem is Song && newItem is Song -> + SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + else -> DetailListAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + } + } + } +} 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 8b7e9abae..62563f159 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -438,7 +438,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) 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 c3fc735b8..31aef6c1e 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 @@ -281,7 +280,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/Sort.kt b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt index 0aed7203c..3be7b0051 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt @@ -114,23 +114,28 @@ data class Sort(val mode: Mode, val direction: Direction) { } private fun songsInPlace(songs: MutableList) { - songs.sortWith(mode.getSongComparator(direction)) + val comparator = mode.getSongComparator(direction) ?: return + songs.sortWith(comparator) } private fun albumsInPlace(albums: MutableList) { - albums.sortWith(mode.getAlbumComparator(direction)) + val comparator = mode.getAlbumComparator(direction) ?: return + albums.sortWith(comparator) } private fun artistsInPlace(artists: MutableList) { - artists.sortWith(mode.getArtistComparator(direction)) + val comparator = mode.getArtistComparator(direction) ?: return + artists.sortWith(comparator) } private fun genresInPlace(genres: MutableList) { - genres.sortWith(mode.getGenreComparator(direction)) + val comparator = mode.getGenreComparator(direction) ?: return + genres.sortWith(comparator) } private fun playlistsInPlace(playlists: MutableList) { - playlists.sortWith(mode.getPlaylistComparator(direction)) + val comparator = mode.getPlaylistComparator(direction) ?: return + playlists.sortWith(comparator) } /** @@ -160,50 +165,57 @@ data class Sort(val mode: Mode, val direction: Direction) { * 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() - } + open 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() - } - + open 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() - } + open 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() - } + open 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. */ - open fun getPlaylistComparator(direction: Direction): Comparator { - throw UnsupportedOperationException() + open fun getPlaylistComparator(direction: Direction): Comparator? = null + + /** + * Sort by the item's natural order. + * + * @see Music.sortName + */ + object ByNone : Mode() { + override val intCode: Int + get() = IntegerTable.SORT_BY_NONE + + override val itemId: Int + get() = R.id.option_sort_none } /** @@ -614,6 +626,7 @@ data class Sort(val mode: Mode, val direction: Direction) { */ fun fromIntCode(intCode: Int) = when (intCode) { + ByNone.intCode -> ByNone ByName.intCode -> ByName ByArtist.intCode -> ByArtist ByAlbum.intCode -> ByAlbum @@ -635,6 +648,7 @@ data class Sort(val mode: Mode, val direction: Direction) { */ fun fromItemId(@IdRes itemId: Int) = when (itemId) { + ByNone.itemId -> ByNone ByName.itemId -> ByName ByAlbum.itemId -> ByAlbum ByArtist.itemId -> ByArtist 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 23ce121ad..ebd3c8152 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -46,6 +46,7 @@ interface MusicSettings : Settings { var multiValueSeparators: String /** Whether to trim english articles with song sort names. */ val automaticSortNames: Boolean + // TODO: Move sort settings to list module /** The [Sort] mode used in [Song] lists. */ var songSort: Sort /** The [Sort] mode used in [Album] lists. */ @@ -62,8 +63,8 @@ interface MusicSettings : Settings { var artistSongSort: Sort /** The [Sort] mode used in a [Genre]'s [Song] list. */ var genreSongSort: Sort - /** The [Sort] mode used in a [Playlist]'s [Song] list, or null if sorting by original ordering. */ - var playlistSongSort: Sort? + /** The [Sort] mode used in a [Playlist]'s [Song] list. */ + var playlistSongSort: Sort interface Listener { /** Called when a setting controlling how music is loaded has changed. */ @@ -224,12 +225,15 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context } } - override var playlistSongSort: Sort? - get() = Sort.fromIntCode(sharedPreferences.getInt( + override var playlistSongSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( getString(R.string.set_key_playlist_songs_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByNone, Sort.Direction.ASCENDING) set(value) { sharedPreferences.edit { - putInt(getString(R.string.lbl_playlist), value?.intCode ?: -1) + putInt(getString(R.string.set_key_playlist_songs_sort), value.intCode) apply() } } 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 index 5e1b86860..593321c71 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.user import javax.inject.Inject import kotlinx.coroutines.channels.Channel import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.device.DeviceLibrary @@ -75,5 +76,17 @@ private class UserLibraryImpl( override val playlists: List get() = playlistMap.values.toList() + init { + val uid = Music.UID.auxio(MusicMode.PLAYLISTS) { update("Playlist 1".toByteArray()) } + playlistMap[uid] = + PlaylistImpl( + RawPlaylist( + PlaylistInfo(uid, "Playlist 1"), + deviceLibrary.songs.slice(10..30).map { PlaylistSong(it.uid) }), + deviceLibrary, + musicSettings, + ) + } + override fun findPlaylist(uid: Music.UID) = playlistMap[uid] } 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 f000a02b5..2b3d43e9e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.playback import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.room.util.query import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job 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 c0facb71e..b7b693a85 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -178,7 +178,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() 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 100% rename from app/src/main/res/menu/menu_genre_artist_detail.xml rename to app/src/main/res/menu/menu_parent_detail.xml diff --git a/app/src/main/res/menu/menu_playlist_sort.xml b/app/src/main/res/menu/menu_playlist_sort.xml new file mode 100644 index 000000000..167fae26b --- /dev/null +++ b/app/src/main/res/menu/menu_playlist_sort.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + \ No newline at end of file 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/values/strings.xml b/app/src/main/res/values/strings.xml index af89461bd..363bbad06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -86,6 +86,7 @@ All + None Name Date Duration 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 83b6f7e2d..3bed80869 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt @@ -60,4 +60,7 @@ open class FakeMusicSettings : MusicSettings { override var genreSongSort: Sort get() = throw NotImplementedError() set(_) = throw NotImplementedError() + override var playlistSongSort: Sort? + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() } From 52e06201498a56ebac6ad299f1bf88a5199b6791 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 22 Mar 2023 19:59:29 -0600 Subject: [PATCH 11/88] music: fix failing tests --- app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3bed80869..757fd69fc 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt @@ -60,7 +60,7 @@ open class FakeMusicSettings : MusicSettings { override var genreSongSort: Sort get() = throw NotImplementedError() set(_) = throw NotImplementedError() - override var playlistSongSort: Sort? + override var playlistSongSort: Sort get() = throw NotImplementedError() set(_) = throw NotImplementedError() } From f388e492aab5e6cd53b778760a973a80ed8749fb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 23 Mar 2023 16:07:56 -0600 Subject: [PATCH 12/88] playback: add playlist support Add playlist support to the playback code. --- CHANGELOG.md | 5 +- .../auxio/detail/PlaylistDetailFragment.kt | 16 ++---- .../org/oxycblt/auxio/list/ListFragment.kt | 22 ++++---- .../auxio/playback/PlaybackViewModel.kt | 51 +++++++++++++++++-- .../oxycblt/auxio/music/FakeMusicSettings.kt | 2 +- 5 files changed, 66 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df765a896..31c416554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,17 +3,16 @@ ## dev #### 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 diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index e0b145b04..f0e8e64ba 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -26,7 +26,6 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.ConcatAdapter -import androidx.recyclerview.widget.RecyclerView import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R @@ -114,16 +113,15 @@ class PlaylistDetailFragment : return true } - // TODO: Handle val currentPlaylist = unlikelyToBeNull(detailModel.currentPlaylist.value) return when (item.itemId) { R.id.action_play_next -> { - // playbackModel.playNext(currentPlaylist) + playbackModel.playNext(currentPlaylist) requireContext().showToast(R.string.lng_queue_added) true } R.id.action_queue_add -> { - // playbackModel.addToQueue(currentPlaylist) + playbackModel.addToQueue(currentPlaylist) requireContext().showToast(R.string.lng_queue_added) true } @@ -131,12 +129,8 @@ class PlaylistDetailFragment : } } - override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) { - // TODO: Handle - } - override fun onRealClick(item: Song) { - // TODO: Handle + playbackModel.playFromPlaylist(item, unlikelyToBeNull(detailModel.currentPlaylist.value)) } override fun onOpenMenu(item: Song, anchor: View) { @@ -145,11 +139,11 @@ class PlaylistDetailFragment : override fun onPlay() { // TODO: Handle - // playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value)) + playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value)) } override fun onShuffle() { - // playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value)) + playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value)) } override fun onOpenSortMenu(anchor: View) { 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 31aef6c1e..c30909c33 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -58,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) @@ -68,7 +68,7 @@ abstract class ListFragment : } } - override fun onSelect(item: T) { + final override fun onSelect(item: T) { selectionModel.select(item) } @@ -222,26 +222,26 @@ abstract class ListFragment : * * @param anchor The [View] to anchor the menu to. * @param menuRes The resource of the menu to load. - * @param genre The [Playlist] to create the menu for. + * @param playlist The [Playlist] to create the menu for. */ - protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Playlist) { - logD("Launching new genre menu: ${genre.rawName}") + protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) { + logD("Launching new playlist menu: ${playlist.rawName}") openMusicMenuImpl(anchor, menuRes) { when (it.itemId) { R.id.action_play -> { - // playbackModel.play(genre) + playbackModel.play(playlist) } R.id.action_shuffle -> { - // playbackModel.shuffle(genre) + playbackModel.shuffle(playlist) } R.id.action_play_next -> { - // playbackModel.playNext(genre) - // requireContext().showToast(R.string.lng_queue_added) + playbackModel.playNext(playlist) + requireContext().showToast(R.string.lng_queue_added) } R.id.action_queue_add -> { - // playbackModel.addToQueue(genre) - // requireContext().showToast(R.string.lng_queue_added) + playbackModel.addToQueue(playlist) + requireContext().showToast(R.string.lng_queue_added) } else -> { error("Unexpected menu item selected") 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 2b3d43e9e..d94e38621 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. @@ -200,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 @@ -216,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,6 +248,13 @@ constructor( */ fun play(genre: Genre) = playImpl(null, genre, false) + /** + * Play a [Playlist]. + * + * @param playlist The [Playlist] to play. + */ + fun play(playlist: Playlist) = playImpl(null, playlist, false) + /** * Play a [Music] selection. * @@ -260,12 +278,19 @@ 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 [Playlist]. + * + * @param playlist The [Playlist] to shuffle. + */ + fun shuffle(playlist: Playlist) = playImpl(null, playlist, true) + /** * Shuffle a [Music] selection. * @@ -292,7 +317,7 @@ constructor( null -> musicSettings.songSort } val songs = parent?.songs ?: deviceLibrary.songs - val queue = sort?.songs(songs) ?: songs + val queue = sort.songs(songs) playbackManager.play(song, parent, queue, shuffled) } @@ -365,6 +390,15 @@ constructor( playbackManager.playNext(musicSettings.genreSongSort.songs(genre.songs)) } + /** + * Add a [Playlist] to the top of the queue. + * + * @param playlist The [Playlist] to add. + */ + fun playNext(playlist: Playlist) { + playbackManager.playNext(musicSettings.playlistSongSort.songs(playlist.songs)) + } + /** * Add a selection to the top of the queue. * @@ -410,6 +444,15 @@ constructor( playbackManager.addToQueue(musicSettings.genreSongSort.songs(genre.songs)) } + /** + * Add a [Playlist] to the end of the queue. + * + * @param playlist The [Playlist] to add. + */ + fun addToQueue(playlist: Playlist) { + playbackManager.addToQueue(musicSettings.playlistSongSort.songs(playlist.songs)) + } + /** * Add a selection to the end of the queue. * @@ -494,7 +537,7 @@ constructor( is Album -> musicSettings.albumSongSort.songs(it.songs) is Artist -> musicSettings.artistSongSort.songs(it.songs) is Genre -> musicSettings.genreSongSort.songs(it.songs) - is Playlist -> musicSettings.playlistSongSort?.songs(it.songs) ?: it.songs + is Playlist -> musicSettings.playlistSongSort.songs(it.songs) } } } 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 757fd69fc..3aee23131 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt @@ -50,7 +50,7 @@ open class FakeMusicSettings : MusicSettings { set(_) = throw NotImplementedError() override var playlistSort: Sort get() = throw NotImplementedError() - set(value) = throw NotImplementedError() + set(_) = throw NotImplementedError() override var albumSongSort: Sort get() = throw NotImplementedError() set(_) = throw NotImplementedError() From 97b63992b574f34947a541ee00c7d4f6a0b45565 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 23 Mar 2023 17:15:38 -0600 Subject: [PATCH 13/88] music: make playlist uids random Make playlist UIDs randomly generated. This will allow multiple playlists with the same name, which may be useful. --- .../java/org/oxycblt/auxio/music/Music.kt | 43 ++-------------- .../auxio/music/device/DeviceLibrary.kt | 4 +- .../auxio/music/device/DeviceMusicImpl.kt | 51 +++++++++++++++++-- .../oxycblt/auxio/music/user/PlaylistImpl.kt | 28 +++++++--- .../oxycblt/auxio/music/user/UserLibrary.kt | 17 ++----- 5 files changed, 79 insertions(+), 64 deletions(-) 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 a764ef8ab..098aae9d9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -22,7 +22,6 @@ 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 @@ -147,47 +146,13 @@ sealed interface Music : Item { companion object { /** - * Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective, - * unlikely-to-change metadata of the music. + * Creates an Auxio-style [UID] with a [UUID] generated by internal Auxio code. * * @param mode The analogous [MusicMode] of the item that created this [UID]. - * @param updates Block to update the [MessageDigest] hash with the metadata of the - * item. Make sure the metadata hashed semantically aligns with the format - * specification. + * @param uuid The generated [UUID] for this item. * @return A new auxio-style [UID]. */ - fun auxio(mode: MusicMode, updates: MessageDigest.() -> Unit): UID { - val digest = - MessageDigest.getInstance("SHA-256").run { - updates() - digest() - } - // Convert the digest to a UUID. This does cleave off some of the hash, but this - // is considered okay. - val uuid = - UUID( - digest[0] - .toLong() - .shl(56) - .or(digest[1].toLong().and(0xFF).shl(48)) - .or(digest[2].toLong().and(0xFF).shl(40)) - .or(digest[3].toLong().and(0xFF).shl(32)) - .or(digest[4].toLong().and(0xFF).shl(24)) - .or(digest[5].toLong().and(0xFF).shl(16)) - .or(digest[6].toLong().and(0xFF).shl(8)) - .or(digest[7].toLong().and(0xFF)), - digest[8] - .toLong() - .shl(56) - .or(digest[9].toLong().and(0xFF).shl(48)) - .or(digest[10].toLong().and(0xFF).shl(40)) - .or(digest[11].toLong().and(0xFF).shl(32)) - .or(digest[12].toLong().and(0xFF).shl(24)) - .or(digest[13].toLong().and(0xFF).shl(16)) - .or(digest[14].toLong().and(0xFF).shl(8)) - .or(digest[15].toLong().and(0xFF))) - return UID(Format.AUXIO, mode, uuid) - } + fun auxio(mode: MusicMode, uuid: UUID) = UID(Format.AUXIO, mode, uuid) /** * Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID @@ -198,7 +163,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. diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index a19aec5b4..404aa8f0c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -32,8 +32,8 @@ import org.oxycblt.auxio.util.logD * 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 */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index f81644d10..88514313f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.device import android.content.Context import androidx.annotation.VisibleForTesting import java.security.MessageDigest +import java.util.* import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* @@ -48,7 +49,7 @@ class SongImpl(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) } - ?: Music.UID.auxio(MusicMode.SONGS) { + ?: createHashedUid(MusicMode.SONGS) { // Song UIDs are based on the raw data without parsing so that they remain // consistent across music setting changes. Parents are not held up to the // same standard since grouping is already inherently linked to settings. @@ -231,7 +232,7 @@ class AlbumImpl( override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) } - ?: Music.UID.auxio(MusicMode.ALBUMS) { + ?: createHashedUid(MusicMode.ALBUMS) { // Hash based on only names despite the presence of a date to increase stability. // I don't know if there is any situation where an artist will have two albums with // the exact same name, but if there is, I would love to know. @@ -330,7 +331,7 @@ class ArtistImpl( override val uid = // 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) } + ?: createHashedUid(MusicMode.ARTISTS) { update(rawArtist.name) } override val rawName = rawArtist.name override val rawSortName = rawArtist.sortName override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) } @@ -415,7 +416,7 @@ class GenreImpl( musicSettings: MusicSettings, override val songs: List ) : Genre { - override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) } + override val uid = createHashedUid(MusicMode.GENRES) { update(rawGenre.name) } override val rawName = rawGenre.name override val rawSortName = rawName override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) } @@ -473,6 +474,48 @@ class GenreImpl( } } +/** + * Generate a [Music.UID] derived from the hash of objective music metadata. + * + * @param mode The analogous [MusicMode] of the item that created this [UID]. + * @param updates Block to update the [MessageDigest] hash with the metadata of the item. Make sure + * the metadata hashed semantically aligns with the format specification. + * @return A new [Music.UID] of Auxio format whose [UUID] was derived from the SHA-256 hash of the + * metadata given. + */ +fun createHashedUid(mode: MusicMode, updates: MessageDigest.() -> Unit): Music.UID { + val digest = + MessageDigest.getInstance("SHA-256").run { + updates() + digest() + } + // Convert the digest to a UUID. This does cleave off some of the hash, but this + // is considered okay. + val uuid = + UUID( + digest[0] + .toLong() + .shl(56) + .or(digest[1].toLong().and(0xFF).shl(48)) + .or(digest[2].toLong().and(0xFF).shl(40)) + .or(digest[3].toLong().and(0xFF).shl(32)) + .or(digest[4].toLong().and(0xFF).shl(24)) + .or(digest[5].toLong().and(0xFF).shl(16)) + .or(digest[6].toLong().and(0xFF).shl(8)) + .or(digest[7].toLong().and(0xFF)), + digest[8] + .toLong() + .shl(56) + .or(digest[9].toLong().and(0xFF).shl(48)) + .or(digest[10].toLong().and(0xFF).shl(40)) + .or(digest[11].toLong().and(0xFF).shl(32)) + .or(digest[12].toLong().and(0xFF).shl(24)) + .or(digest[13].toLong().and(0xFF).shl(16)) + .or(digest[14].toLong().and(0xFF).shl(8)) + .or(digest[15].toLong().and(0xFF))) + return Music.UID.auxio(mode, uuid) +} + /** * Update a [MessageDigest] with a lowercase [String]. * 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 index e5432e1d8..90a5da8f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -19,20 +19,36 @@ package org.oxycblt.auxio.music.user import android.content.Context +import java.util.* import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.device.DeviceLibrary -class PlaylistImpl( - rawPlaylist: RawPlaylist, - deviceLibrary: DeviceLibrary, +class PlaylistImpl +private constructor( + override val uid: Music.UID, + override val rawName: String, + override val songs: List, musicSettings: MusicSettings ) : Playlist { - override val uid = rawPlaylist.playlistInfo.playlistUid - override val rawName = rawPlaylist.playlistInfo.name + constructor( + name: String, + songs: List, + musicSettings: MusicSettings + ) : this(Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()), name, songs, musicSettings) + + constructor( + rawPlaylist: RawPlaylist, + deviceLibrary: DeviceLibrary, + musicSettings: MusicSettings + ) : this( + rawPlaylist.playlistInfo.playlistUid, + rawPlaylist.playlistInfo.name, + rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) }, + musicSettings) + override fun resolveName(context: Context) = rawName override val rawSortName = null override val sortName = SortName(rawName, musicSettings) - override val songs = rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) } override val durationMs = songs.sumOf { it.durationMs } override val albums = songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key } 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 index 593321c71..01fada793 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -20,10 +20,7 @@ package org.oxycblt.auxio.music.user import javax.inject.Inject import kotlinx.coroutines.channels.Channel -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode -import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.device.DeviceLibrary /** @@ -54,6 +51,7 @@ interface UserLibrary { * * @param deviceLibrary Asynchronously populated [DeviceLibrary] that can be obtained later. * This allows database information to be read before the actual instance is constructed. + * @return A new [UserLibrary] with the required implementation. */ suspend fun read(deviceLibrary: Channel): UserLibrary } @@ -77,15 +75,8 @@ private class UserLibraryImpl( get() = playlistMap.values.toList() init { - val uid = Music.UID.auxio(MusicMode.PLAYLISTS) { update("Playlist 1".toByteArray()) } - playlistMap[uid] = - PlaylistImpl( - RawPlaylist( - PlaylistInfo(uid, "Playlist 1"), - deviceLibrary.songs.slice(10..30).map { PlaylistSong(it.uid) }), - deviceLibrary, - musicSettings, - ) + val playlist = PlaylistImpl("Playlist 1", deviceLibrary.songs.slice(58..100), musicSettings) + playlistMap[playlist.uid] = playlist } override fun findPlaylist(uid: Music.UID) = playlistMap[uid] From 7c0b73b699909c4a1ccf01dd50d219775a56759f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 23 Mar 2023 17:40:44 -0600 Subject: [PATCH 14/88] music: move sort name number handling to setting Make the numeric sort name handling added prior dependent on a new "Intelligent Sorting" setting that also controls the article checks. This kind of behavior might not be desirable in all cases, and it makes the setting more consistent anyway. --- CHANGELOG.md | 3 ++ .../java/org/oxycblt/auxio/music/Music.kt | 35 ++++++++++--------- .../org/oxycblt/auxio/music/MusicSettings.kt | 6 ++-- .../auxio/music/device/DeviceMusicImpl.kt | 1 + app/src/main/res/values-be/strings.xml | 4 +-- app/src/main/res/values-cs/strings.xml | 4 +-- app/src/main/res/values-de/strings.xml | 4 +-- app/src/main/res/values-es/strings.xml | 4 +-- app/src/main/res/values-gl/strings.xml | 4 +-- app/src/main/res/values-it/strings.xml | 4 +-- app/src/main/res/values-iw/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 4 +-- app/src/main/res/values-ko/strings.xml | 4 +-- app/src/main/res/values-lt/strings.xml | 4 +-- app/src/main/res/values-pl/strings.xml | 4 +-- app/src/main/res/values-pt-rBR/strings.xml | 4 +-- app/src/main/res/values-pt-rPT/strings.xml | 4 +-- app/src/main/res/values-ru/strings.xml | 4 +-- app/src/main/res/values-tr/strings.xml | 4 +-- app/src/main/res/values-uk/strings.xml | 4 +-- app/src/main/res/values-zh-rCN/strings.xml | 4 +-- app/src/main/res/values/strings.xml | 4 +-- app/src/main/res/xml/preferences_music.xml | 4 +-- .../oxycblt/auxio/music/FakeMusicSettings.kt | 2 +- ...RawMusicTest.kt => DeviceMusicImplTest.kt} | 6 ++-- 25 files changed, 66 insertions(+), 61 deletions(-) rename app/src/test/java/org/oxycblt/auxio/music/device/{RawMusicTest.kt => DeviceMusicImplTest.kt} (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c416554..77c220c1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ 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" + ## 3.0.3 #### What's New 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 098aae9d9..93fc7e581 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -85,7 +85,7 @@ sealed interface Music : Item { * A unique identifier for a piece of music. * * [UID] enables a much cheaper and more reliable form of differentiating music, derived from - * either a hash of meaningful metadata or the MusicBrainz ID spec. Using this enables several + * either internal app information or the MusicBrainz ID spec. Using this enables several * improvements to music management in this app, including: * - Proper differentiation of identical music. It's common for large, well-tagged libraries to * have functionally duplicate items that are differentiated with MusicBrainz IDs, and so @@ -355,7 +355,7 @@ class SortName(name: String, musicSettings: MusicSettings) : Comparable this } } - } - // Parse out numeric portions of the title and use those for sorting, if applicable. - val numericEnd = sortName.indexOfFirst { !it.isDigit() } - when (numericEnd) { - // No numeric component. - 0 -> number = null - // Whole title is numeric. - -1 -> { - number = sortName.toIntOrNull() - sortName = "" - } - // Part of the title is numeric. - else -> { - number = sortName.slice(0 until numericEnd).toIntOrNull() - sortName = sortName.slice(numericEnd until sortName.length) + // Parse out numeric portions of the title and use those for sorting, if applicable. + when (val numericEnd = sortName.indexOfFirst { !it.isDigit() }) { + // No numeric component. + 0 -> number = null + // Whole title is numeric. + -1 -> { + number = sortName.toIntOrNull() + sortName = "" + } + // Part of the title is numeric. + else -> { + number = sortName.slice(0 until numericEnd).toIntOrNull() + sortName = sortName.slice(numericEnd until sortName.length) + } } + } else { + number = null } collationKey = COLLATOR.getCollationKey(sortName) 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 ebd3c8152..adcf337c0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -44,8 +44,8 @@ interface MusicSettings : Settings { val shouldBeObserving: Boolean /** A [String] of characters representing the desired characters to denote multi-value tags. */ var multiValueSeparators: String - /** Whether to trim english articles with song sort names. */ - val automaticSortNames: Boolean + /** Whether to enable more advanced sorting by articles and numbers. */ + val intelligentSorting: Boolean // TODO: Move sort settings to list module /** The [Sort] mode used in [Song] lists. */ var songSort: Sort @@ -115,7 +115,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context } } - override val automaticSortNames: Boolean + override val intelligentSorting: Boolean get() = sharedPreferences.getBoolean(getString(R.string.set_key_auto_sort_names), true) override var songSort: Sort diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 88514313f..a274209e9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -483,6 +483,7 @@ class GenreImpl( * @return A new [Music.UID] of Auxio format whose [UUID] was derived from the SHA-256 hash of the * metadata given. */ +@VisibleForTesting fun createHashedUid(mode: MusicMode, updates: MessageDigest.() -> Unit): Music.UID { val digest = MessageDigest.getInstance("SHA-256").run { diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index bd3c714a2..bc92727aa 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -267,8 +267,8 @@ Прайграванне Канцэртны міні-альбом Міні-альбом рэміксаў - Ігнараваць такія словы, як \"the\", пры сартаванні па імені (лепш за ўсё працуе з англамоўнай музыкай) - Ігнараваць артыклі пры сартаванні + Ігнараваць такія словы, як \"the\", пры сартаванні па імені (лепш за ўсё працуе з англамоўнай музыкай) + Ігнараваць артыклі пры сартаванні Міні-альбомы Міні-альбом \ 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 27ac859f7..fe32967f3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -280,6 +280,6 @@ Knihovna Perzistence Sestupně - 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ě) + 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ě) \ 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 f41ce9065..d197759bb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -271,6 +271,6 @@ Persistenz Lautstärkeanpassung ReplayGain Absteigend - Artikel beim Sortieren ignorieren - Wörter wie „the“ ignorieren (funktioniert am besten mit englischsprachiger Musik) + Artikel beim Sortieren ignorieren + Wörter wie „the“ ignorieren (funktioniert am besten mit englischsprachiger Musik) \ 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 24a9e999f..b51068c59 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -275,6 +275,6 @@ Personalizar los controles y el comportamiento de la interfaz de usuario Biblioteca Descendente - Ignorar artículos al ordenar - Ignorar palabras como \"the\" al ordenar por nombre (funciona mejor con música en inglés) + Ignorar artículos al ordenar + Ignorar palabras como \"the\" al ordenar por nombre (funciona mejor con música en inglés) \ 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 60f2ec924..6eb011b5d 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -145,7 +145,7 @@ Coma (,) Punto e coma (;) Mostrar só artistas que estean directamente acreditados nun álbum (funciona mellos en bibliotecas ben etiquetadas) - Ignorar artigos ao ordenar + Ignorar artigos ao ordenar Agochar colaboradores Imaxes Portadas de álbums @@ -241,7 +241,7 @@ %d artistas Máis (+) - Ignorar palabras como \"the\" ao ordenar por nome (funciona mellor con música en inglés) + Ignorar palabras como \"the\" ao ordenar por nome (funciona mellor con música en inglés) Modo Xestionar dende onde se carga a música Pausar cando se repite unha canción diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index cd1685236..665e6a720 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -275,6 +275,6 @@ Personalizza controlli e comportamento dell\'UI Configura comportamento di suono e riproduzione Discendente - Ignora gli articoli durante l\'ordinamento - Ignora parole come \"the\" durante l\'ordinamento per nome (funziona meglio con la musica in lingua inglese) + Ignora gli articoli durante l\'ordinamento + Ignora parole come \"the\" durante l\'ordinamento per nome (funziona meglio con la musica in lingua inglese) \ No newline at end of file diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 9765c8d11..cd948d6b6 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -149,7 +149,7 @@ קו נטוי (/) אזהרה: השימוש בהגדרה זו עלול לגרום לחלק מהתגיות להיות מפורשות באופן שגוי כבעלות מספר ערכים. ניתן לפתור זאת על ידי הכנסת קו נטוי אחורי (\\) לפני תווים מפרידים לא רצויים. איכות גבוהה - התעלמ~י ממילים כמו \"The\" (\"ה-\") בעת סידור על פי שם (עובד באופן הכי טוב עם מוזיקה בשפה האנגלית) + התעלמ~י ממילים כמו \"The\" (\"ה-\") בעת סידור על פי שם (עובד באופן הכי טוב עם מוזיקה בשפה האנגלית) תמונות התאמ~י התנהגות צליל והשמעה התחל~י לנגן תמיד ברגע שמחוברות אוזניות (עלול לא לעבוד בכל המערכות) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 46b54ec6a..1b8dfa355 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -242,8 +242,8 @@ モード 複数のタグ値を表す文字を構成する カスタム再生バー アクション - ソート時に記事を無視する - 名前で並べ替えるときに「the」などの単語を無視する (英語の音楽に最適) + ソート時に記事を無視する + 名前で並べ替えるときに「the」などの単語を無視する (英語の音楽に最適) 初期 (高速読み込み) 再生中の場合はアルバムを優先 複数値セパレータ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1efbda080..eeab53168 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -271,6 +271,6 @@ 동작 UI 제어 및 동작 커스텀 내림차순 - 정렬할 때 기사 무시 - 이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함) + 정렬할 때 기사 무시 + 이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함) \ 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 d5ca38980..6bf884ce2 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -269,6 +269,6 @@ Aplankalai Atkaklumas Mažėjantis - Ignoruoti tokius žodžius kaip „the“, kai rūšiuojama pagal pavadinimą (geriausiai veikia su anglų kalbos muzika) - Ignoruoti straipsnius rūšiuojant + Ignoruoti tokius žodžius kaip „the“, kai rūšiuojama pagal pavadinimą (geriausiai veikia su anglų kalbos muzika) + Ignoruoti straipsnius rūšiuojant \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index b8180e778..b06a4b65f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -276,6 +276,6 @@ 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) + 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) \ 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 b834f707c..39f7febb4 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -273,6 +273,6 @@ Comportamento Pastas Descendente - Ignorar artigos ao classificar - Ignore palavras como \"the\" ao classificar por nome (funciona melhor com músicas em inglês) + Ignorar artigos ao classificar + Ignore palavras como \"the\" ao classificar por nome (funciona melhor com músicas em inglês) \ No newline at end of file diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index e4e4f7a32..3e7f7a265 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -273,6 +273,6 @@ Estado de reprodução E comercial (&) Comportamento - Ignorar artigos ao classificar - Ignore palavras como \"the\" ao classificar por nome (funciona melhor com músicas em inglês) + Ignorar artigos ao classificar + Ignore palavras como \"the\" ao classificar por nome (funciona melhor com músicas em inglês) \ 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 25e11e3f7..bfc33bc2f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -278,6 +278,6 @@ Папки Состояние воспроизведения По убыванию - Игнорировать артикли при сортировке - Игнорировать такие слова, как «the», при сортировке по имени (лучше всего работает с англоязычной музыкой) + Игнорировать артикли при сортировке + Игнорировать такие слова, как «the», при сортировке по имени (лучше всего работает с англоязычной музыкой) \ 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 3581a3142..a05a0adfd 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -269,6 +269,6 @@ Arayüz kontrollerini ve davranışını özelleştirin Davranış Ses yüksekliği dengesi ReplayGain - Sıralama yaparken makaleleri yoksay - Ada göre sıralarken \"the\" gibi kelimeleri yok sayın (en iyi ingilizce müzikle çalışır) + Sıralama yaparken makaleleri yoksay + Ada göre sıralarken \"the\" gibi kelimeleri yok sayın (en iyi ingilizce müzikle çalışır) \ 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 c571b19c4..f15b96f58 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -275,6 +275,6 @@ Налаштування звуку і поведінки при відтворенні Папки За спаданням - Ігнорувати артиклі під час сортування - Ігнорування таких слів, як \"the\", під час сортування за назвою (найкраще працює з англомовною музикою) + Ігнорувати артиклі під час сортування + Ігнорування таких слів, як \"the\", під час сортування за назвою (найкраще працює з англомовною музикою) \ 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 dfb1bf0aa..faa26ddf0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -269,6 +269,6 @@ 音乐 配置声音和播放行为 降序 - 排序时忽略冠词 - 按名称排序时忽略类似“the”这样的冠词(对英文歌曲的效果最好) + 排序时忽略冠词 + 按名称排序时忽略类似“the”这样的冠词(对英文歌曲的效果最好) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 363bbad06..d37d2ab0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -216,8 +216,8 @@ Plus (+) Ampersand (&) Warning: Using this setting may result in some tags being incorrectly interpreted as having multiple values. You can resolve this by prefixing unwanted separator characters with a backslash (\\). - Ignore articles when sorting - Ignore words like \"the\" when sorting by name (works best with english-language music) + Intelligent sorting + Correctly sort names that begin with numbers or words like \"the\" (works best with english-language music) Hide collaborators Only show artists that are directly credited on an album (works best on well-tagged libraries) Images diff --git a/app/src/main/res/xml/preferences_music.xml b/app/src/main/res/xml/preferences_music.xml index decbc4090..a46bb1025 100644 --- a/app/src/main/res/xml/preferences_music.xml +++ b/app/src/main/res/xml/preferences_music.xml @@ -23,8 +23,8 @@ + app:summary="@string/set_intelligent_sorting_desc" + app:title="@string/set_intelligent_sorting" /> Date: Fri, 24 Mar 2023 00:52:38 +0100 Subject: [PATCH 15/88] Translations update from Hosted Weblate (#398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Czech) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Galician) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/gl/ * Translated using Weblate (German) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (German) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (French) Currently translated at 70.6% (183 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/ * Translated using Weblate (Italian) Currently translated at 99.2% (257 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/ * Translated using Weblate (Korean) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Polish) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/ * Translated using Weblate (Russian) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Turkish) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/tr/ * Translated using Weblate (Japanese) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ja/ * Translated using Weblate (Croatian) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (259 of 259 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Spanish) Currently translated at 100.0% (260 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Italian) Currently translated at 99.2% (258 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (260 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (260 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Japanese) Currently translated at 100.0% (260 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ja/ * Translated using Weblate (Galician) Currently translated at 100.0% (260 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/gl/ --------- Co-authored-by: Fjuro Co-authored-by: gallegonovato Co-authored-by: BMN Co-authored-by: Eric Co-authored-by: Макар Разин Co-authored-by: C. Rüdinger Co-authored-by: atilluF Co-authored-by: SakiNSA Co-authored-by: Alexander Capehart --- app/src/main/res/values-be/strings.xml | 3 ++ app/src/main/res/values-cs/strings.xml | 3 ++ app/src/main/res/values-de/strings.xml | 9 ++++-- app/src/main/res/values-es/strings.xml | 4 +++ app/src/main/res/values-fr/strings.xml | 36 ++++++++++++++++++++++ app/src/main/res/values-gl/strings.xml | 11 ++++++- app/src/main/res/values-hr/strings.xml | 20 +++++++++++- app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-ja/strings.xml | 4 +++ app/src/main/res/values-ko/strings.xml | 3 ++ app/src/main/res/values-pl/strings.xml | 3 ++ app/src/main/res/values-ru/strings.xml | 3 ++ app/src/main/res/values-tr/strings.xml | 3 ++ app/src/main/res/values-uk/strings.xml | 4 +++ app/src/main/res/values-zh-rCN/strings.xml | 4 +++ 15 files changed, 107 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index bc92727aa..8adbde3f8 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -271,4 +271,7 @@ Ігнараваць артыклі пры сартаванні Міні-альбомы Міні-альбом + Вокладка плэйліст для %s + Плэйліст + Плэйлісты \ 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..8150345a1 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -280,6 +280,9 @@ 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ě) \ 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 d197759bb..39e481ff4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -159,9 +159,9 @@ 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 @@ -194,7 +194,7 @@ Den vorher gespeicherten Wiedergabezustand wiederherstellen (wenn verfügbar) Zustand konnte nicht wiederhergestellt werden EP - EPs + Mini-Alben Single Singles Kompilationen @@ -271,6 +271,9 @@ Persistenz Lautstärkeanpassung ReplayGain Absteigend + Playlist-Bild für %s + Wiedergabeliste + Wiedergabelisten Artikel beim Sortieren ignorieren Wörter wie „the“ ignorieren (funktioniert am besten mit englischsprachiger Musik) \ 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..24a1aa723 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -275,6 +275,10 @@ 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 + Ninguno Ignorar artículos al ordenar Ignorar palabras como \"the\" al ordenar por nombre (funciona mejor con música en inglés) \ 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..4baf5897e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -165,4 +165,40 @@ 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) + Ignorez les mots comme \"the\" lors du tri par nom (fonctionne mieux avec la musique en anglais) + Configurer les caractères qui indiquent plusieurs valeurs de balise + Ignorer les articles lors du tri + 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 (+) \ 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 6eb011b5d..8a7227404 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 @@ -260,4 +260,13 @@ 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 + Ningún \ 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..e52d30b9e 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -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 @@ -253,4 +253,22 @@ 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 + Zanemarite članke prilikom sortiranja + Popisi pjesama + Popis pjesama + Glazba + Ignorirajte riječi poput \"the\" prilikom sortiranja po imenu (najbolje radi s glazbom na engleskom jeziku) + Slika popisa za reprodukciju za %s + Ponašanje \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 665e6a720..16b583129 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -275,6 +275,8 @@ Personalizza controlli e comportamento dell\'UI Configura comportamento di suono e riproduzione Discendente + elenco di riproduzione + Playlist Ignora gli articoli durante l\'ordinamento Ignora parole come \"the\" durante l\'ordinamento per nome (funziona meglio con la musica in lingua inglese) \ 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..686f03f87 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -262,4 +262,8 @@ ReplayGain プリアンプ %1$s、%2$s UI コントロールと動作をカスタマイズする + プレイリスト + プレイリスト + %s のプレイリスト イメージ + 無し \ 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..e5a38142f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -271,6 +271,9 @@ 동작 UI 제어 및 동작 커스텀 내림차순 + 재생목록 + 재생목록 + %s의 재생 목록 이미지 정렬할 때 기사 무시 이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함) \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index b06a4b65f..96183c8a1 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -276,6 +276,9 @@ Nie można wyczyścić stanu odtwarzania Nie można zapisać stanu odtwarzania Malejąco + Playlisty + Playlist + Obraz playlisty dla %s 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) \ 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..c998542bc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -278,6 +278,9 @@ Папки Состояние воспроизведения По убыванию + Плейлист + Плейлисты + Обложка плейлиста для %s Игнорировать артикли при сортировке Игнорировать такие слова, как «the», при сортировке по имени (лучше всего работает с англоязычной музыкой) \ 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..5a558da02 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -269,6 +269,9 @@ 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) \ 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..fba72e2c8 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -275,6 +275,10 @@ Налаштування звуку і поведінки при відтворенні Папки За спаданням + Зображення списку відтворення для %s + Список відтворення + Списки відтворення + Немає Ігнорувати артиклі під час сортування Ігнорування таких слів, як \"the\", під час сортування за назвою (найкраще працює з англомовною музикою) \ 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..4242bbee6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -269,6 +269,10 @@ 音乐 配置声音和播放行为 降序 + 播放列表 + 播放列表 + %s 的播放列表图片 + 排序时忽略冠词 按名称排序时忽略类似“the”这样的冠词(对英文歌曲的效果最好) \ No newline at end of file From 9988a1b76b46cef5bacfd0593ebf63e849437174 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 25 Mar 2023 14:36:22 -0600 Subject: [PATCH 16/88] music: add framework for playlist mutation Add the boilerplate for basic playlist creation/addition. No integration in UI yet. --- CHANGELOG.md | 3 - .../oxycblt/auxio/detail/DetailViewModel.kt | 4 +- .../oxycblt/auxio/music/MusicRepository.kt | 8 +-- .../auxio/music/device/DeviceLibrary.kt | 6 +- .../auxio/music/device/DeviceModule.kt | 3 +- .../auxio/music/device/DeviceMusicImpl.kt | 4 +- .../oxycblt/auxio/music/device/RawMusic.kt | 6 +- .../auxio/music/fs/MediaStoreExtractor.kt | 12 ++-- .../oxycblt/auxio/music/metadata/AudioInfo.kt | 8 +-- .../auxio/music/metadata/MetadataModule.kt | 6 +- .../oxycblt/auxio/music/metadata/TagWorker.kt | 64 ++++++----------- .../oxycblt/auxio/music/user/PlaylistImpl.kt | 69 ++++++++++++++----- .../oxycblt/auxio/music/user/UserLibrary.kt | 54 ++++++++++++--- .../oxycblt/auxio/music/user/UserModule.kt | 2 +- 14 files changed, 142 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 238d43aab..3cb8cd40a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,6 @@ deletion #### What's Changed - "Ignore articles when sorting" is now "Intelligent sorting" -#### What's Changed -- "Ignore articles when sorting" is now "Intelligent sorting" - ## 3.0.3 #### What's New 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 8369fcf67..8b2baac6c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -53,7 +53,7 @@ class DetailViewModel @Inject constructor( private val musicRepository: MusicRepository, - private val audioInfoProvider: AudioInfo.Provider, + private val audioInfoFactory: AudioInfo.Factory, private val musicSettings: MusicSettings, private val playbackSettings: PlaybackSettings ) : ViewModel(), MusicRepository.UpdateListener { @@ -308,7 +308,7 @@ constructor( _songAudioInfo.value = null currentSongJob = viewModelScope.launch(Dispatchers.IO) { - val info = audioInfoProvider.extract(song) + val info = audioInfoFactory.extract(song) yield() _songAudioInfo.value = info } 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 6295dda5b..bcd001aa7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -172,8 +172,8 @@ constructor( private val cacheRepository: CacheRepository, private val mediaStoreExtractor: MediaStoreExtractor, private val tagExtractor: TagExtractor, - private val deviceLibraryProvider: DeviceLibrary.Provider, - private val userLibraryProvider: UserLibrary.Provider + private val deviceLibraryFactory: DeviceLibrary.Factory, + private val userLibraryFactory: UserLibrary.Factory ) : MusicRepository { private val updateListeners = mutableListOf() private val indexingListeners = mutableListOf() @@ -314,9 +314,9 @@ constructor( val deviceLibraryChannel = Channel() val deviceLibraryJob = worker.scope.async(Dispatchers.Main) { - deviceLibraryProvider.create(rawSongs).also { deviceLibraryChannel.send(it) } + deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) } } - val userLibraryJob = worker.scope.async { userLibraryProvider.read(deviceLibraryChannel) } + val userLibraryJob = worker.scope.async { userLibraryFactory.read(deviceLibraryChannel) } if (cache == null || cache.invalidated) { cacheRepository.writeCache(rawSongs) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 404aa8f0c..f292303b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -89,7 +89,7 @@ interface DeviceLibrary { fun findGenre(uid: Music.UID): Genre? /** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */ - interface Provider { + interface Factory { /** * Create a new [DeviceLibrary]. * @@ -110,8 +110,8 @@ interface DeviceLibrary { } } -class DeviceLibraryProviderImpl @Inject constructor(private val musicSettings: MusicSettings) : - DeviceLibrary.Provider { +class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) : + DeviceLibrary.Factory { override suspend fun create(rawSongs: List): DeviceLibrary = DeviceLibraryImpl(rawSongs, musicSettings) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt index 3bb3de657..41b69a498 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceModule.kt @@ -26,6 +26,5 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) interface DeviceModule { - @Binds - fun deviceLibraryProvider(providerImpl: DeviceLibraryProviderImpl): DeviceLibrary.Provider + @Binds fun deviceLibraryProvider(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index a274209e9..76f78c4e2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -342,8 +342,8 @@ class ArtistImpl( 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. + // Note: Append song contents to MusicParent equality so that artists with + // the same UID but different songs are not equal. override fun hashCode() = 31 * uid.hashCode() + songs.hashCode() override fun equals(other: Any?) = other is ArtistImpl && uid == other.uid && songs == other.songs diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index 86b8df817..571e90ff5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -186,13 +186,13 @@ class RawGenre( /** @see Music.rawName */ 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/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index f97318d5c..9e3d1f2d5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -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 } } @@ -311,9 +311,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 +355,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/metadata/AudioInfo.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt index 5f801a1e5..2dc906563 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt @@ -43,7 +43,7 @@ data class AudioInfo( val resolvedMimeType: MimeType ) { /** Implements the process of extracting [AudioInfo] from a given [Song]. */ - interface Provider { + interface Factory { /** * Extract the [AudioInfo] of a given [Song]. * @@ -55,12 +55,12 @@ data class AudioInfo( } /** - * A framework-backed implementation of [AudioInfo.Provider]. + * A framework-backed implementation of [AudioInfo.Factory]. * * @param context [Context] required to read audio files. */ -class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) : - AudioInfo.Provider { +class AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val context: Context) : + AudioInfo.Factory { override suspend fun extract(song: Song): AudioInfo { // While we would use ExoPlayer to extract this information, it doesn't support 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..650acbe60 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 audioInfoProvider(factory: AudioInfoFactoryImpl): AudioInfo.Factory } 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 04ca06409..1a553211c 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 @@ -56,14 +56,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 +107,6 @@ private constructor(private val rawSong: RawSong, private val future: Future>) { // Song textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() } @@ -169,16 +175,6 @@ 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 +208,6 @@ private constructor(private val rawSong: RawSong, private val future: Future>) { // Song comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() } @@ -277,21 +268,6 @@ private constructor(private val rawSong: RawSong, private val future: Future, - musicSettings: MusicSettings + override val sortName: SortName, + override val songs: List ) : Playlist { - constructor( - name: String, - songs: List, - musicSettings: MusicSettings - ) : this(Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()), name, songs, musicSettings) - - constructor( - rawPlaylist: RawPlaylist, - deviceLibrary: DeviceLibrary, - musicSettings: MusicSettings - ) : this( - rawPlaylist.playlistInfo.playlistUid, - rawPlaylist.playlistInfo.name, - rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) }, - musicSettings) - override fun resolveName(context: Context) = rawName override val rawSortName = null - override val sortName = SortName(rawName, musicSettings) 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 [Song]s. + * + * @param songs The new [Song]s to use. + */ + fun edit(songs: List) = PlaylistImpl(uid, rawName, sortName, 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)) + + 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 new(name: String, songs: List, musicSettings: MusicSettings) = + PlaylistImpl( + Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()), + name, + SortName(name, 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, + rawPlaylist.playlistInfo.name, + SortName(rawPlaylist.playlistInfo.name, musicSettings), + rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) }) + } } 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 index 01fada793..f34ae6648 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -45,23 +45,46 @@ interface UserLibrary { fun findPlaylist(uid: Music.UID): Playlist? /** Constructs a [UserLibrary] implementation in an asynchronous manner. */ - interface Provider { + interface Factory { /** * Create a new [UserLibrary]. * * @param deviceLibrary Asynchronously populated [DeviceLibrary] that can be obtained later. * This allows database information to be read before the actual instance is constructed. - * @return A new [UserLibrary] with the required implementation. + * @return A new [MutableUserLibrary] with the required implementation. */ - suspend fun read(deviceLibrary: Channel): UserLibrary + suspend fun read(deviceLibrary: Channel): MutableUserLibrary } } -class UserLibraryProviderImpl +/** + * 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]. + */ + fun createPlaylist(name: String, songs: List) + + /** + * Add [Song]s to a [Playlist]. + * + * @param playlist The [Playlist] to add to. Must currently exist. + */ + fun addToPlaylist(playlist: Playlist, songs: List) +} + +class UserLibraryFactoryImpl @Inject constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : - UserLibrary.Provider { - override suspend fun read(deviceLibrary: Channel): UserLibrary = + UserLibrary.Factory { + override suspend fun read(deviceLibrary: Channel): MutableUserLibrary = UserLibraryImpl(playlistDao, deviceLibrary.receive(), musicSettings) } @@ -69,15 +92,28 @@ private class UserLibraryImpl( private val playlistDao: PlaylistDao, private val deviceLibrary: DeviceLibrary, private val musicSettings: MusicSettings -) : UserLibrary { +) : MutableUserLibrary { private val playlistMap = mutableMapOf() override val playlists: List get() = playlistMap.values.toList() init { - val playlist = PlaylistImpl("Playlist 1", deviceLibrary.songs.slice(58..100), musicSettings) - playlistMap[playlist.uid] = playlist + // TODO: Actually read playlists + createPlaylist("Playlist 1", deviceLibrary.songs.slice(58..100)) } override fun findPlaylist(uid: Music.UID) = playlistMap[uid] + + @Synchronized + override fun createPlaylist(name: String, songs: List) { + val playlistImpl = PlaylistImpl.new(name, songs, musicSettings) + playlistMap[playlistImpl.uid] = playlistImpl + } + + @Synchronized + override fun addToPlaylist(playlist: Playlist, songs: List) { + val playlistImpl = + requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } + playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } + } } 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 index 9c92c0ca5..b4c7ef6a4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt @@ -30,7 +30,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) interface UserModule { - @Binds fun userLibaryProvider(provider: UserLibraryProviderImpl): UserLibrary.Provider + @Binds fun userLibaryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory } @Module From 829e2a42c4e40fac0f007ac5a8f6b49808c41abf Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 25 Mar 2023 14:37:55 -0600 Subject: [PATCH 17/88] home: add playlist add button Refactor the home FAB to switch to playlist addition button when at the playlist tab. --- .../auxio/home/FlipFloatingActionButton.kt | 101 ++++++++++++++++++ .../org/oxycblt/auxio/home/HomeFragment.kt | 23 ++-- .../org/oxycblt/auxio/home/HomeViewModel.kt | 1 + .../oxycblt/auxio/music/MusicRepository.kt | 2 + .../org/oxycblt/auxio/music/MusicViewModel.kt | 8 ++ app/src/main/res/layout/fragment_home.xml | 6 +- app/src/main/res/values/strings.xml | 1 + 7 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt 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..c2b2842a3 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt @@ -0,0 +1,101 @@ +/* + * 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 + flipping = true + pendingConfig = PendingConfig(iconRes, contentDescriptionRes, clickListener) + // 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 62563f159..0827a00b9 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -147,12 +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( + homeModel.songsList, homeModel.isFastScrolling, homeModel.currentTabMode, ::updateFab) collectImmediately(musicModel.indexingState, ::updateIndexerState) collect(navModel.exploreNavigationItem.flow, ::handleNavigation) collectImmediately(selectionModel.selected, ::updateSelection) @@ -268,6 +267,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) { @@ -286,8 +286,7 @@ class HomeFragment : } val sortMenu = - unlikelyToBeNull( - requireBinding().homeToolbar.menu.findItem(R.id.submenu_sorting).subMenu) + unlikelyToBeNull(binding.homeToolbar.menu.findItem(R.id.submenu_sorting).subMenu) val toHighlight = homeModel.getSortForTab(tabMode) for (option in sortMenu) { @@ -308,7 +307,7 @@ 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 @@ -316,6 +315,16 @@ class HomeFragment : 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?) { @@ -419,7 +428,7 @@ class HomeFragment : } } - private fun updateFab(songs: List, isFastScrolling: Boolean) { + private fun updateFab(songs: List, isFastScrolling: Boolean, currentTabMode: MusicMode) { val binding = requireBinding() // If there are no songs, it's likely that the library has not been loaded, so // displaying the shuffle FAB makes no sense. We also don't want the fast scroll 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 bc2baefe9..8b4e6d581 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -137,6 +137,7 @@ constructor( 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. 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 bcd001aa7..ac0bf498b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -189,6 +189,7 @@ constructor( @Synchronized override fun addUpdateListener(listener: MusicRepository.UpdateListener) { updateListeners.add(listener) + listener.onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = true)) } @Synchronized @@ -199,6 +200,7 @@ constructor( @Synchronized override fun addIndexingListener(listener: MusicRepository.IndexingListener) { indexingListeners.add(listener) + listener.onIndexingStateChanged() } @Synchronized 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 40746dd9c..c613fc8ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -78,6 +78,14 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos musicRepository.requestIndex(false) } + /** + * Create a new generic playlist. + * @param name The name of the new playlist. If null, the user will be prompted for a name. + */ + fun createPlaylist(name: String? = null) { + // TODO: Implement + } + /** * Non-manipulated statistics bound the last successful music load. * diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 5e8da8d81..8fb877122 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -123,14 +123,12 @@ app:layout_anchor="@id/home_content" app:layout_anchorGravity="bottom|end"> - + android:layout_margin="@dimen/spacing_medium" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d37d2ab0a..c0de9a5c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,6 +297,7 @@ Change repeat mode Turn shuffle on or off Shuffle all songs + Create a new playlist Stop playback Remove this queue song From 67e67ca1d0188a46522341ccc22af345b0773f1f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 25 Mar 2023 15:30:13 -0600 Subject: [PATCH 18/88] playback: generalize make playback panel opening If the user clicks on the playback bar in any context, including the queue view, open the playback panel. This adds another means to closing the queue that does not involve swiping. Resolves #402. --- CHANGELOG.md | 5 ++++ .../java/org/oxycblt/auxio/MainFragment.kt | 23 ++++++++++++++----- .../org/oxycblt/auxio/music/MusicViewModel.kt | 1 + .../auxio/playback/PlaybackBarFragment.kt | 2 +- .../auxio/playback/PlaybackPanelFragment.kt | 4 +++- .../oxycblt/auxio/ui/NavigationViewModel.kt | 4 ++-- 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb8cd40a..b18f594f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## dev + +## What's Improved +- Added ability to click on the playback bar to exit the queue view + ## 3.0.4 #### What's New diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 6c1913d5f..8ce51e915 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -268,8 +268,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 +279,7 @@ class MainFragment : private fun handleExploreNavigation(item: Music?) { if (item != null) { - tryCollapseSheets() + tryClosePlaybackPanel() } } @@ -318,22 +318,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 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 c613fc8ea..9397661ba 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -80,6 +80,7 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos /** * Create a new generic playlist. + * * @param name The name of the new playlist. If null, the user will be prompted for a name. */ fun createPlaylist(name: String? = null) { 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..29c0d889e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -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 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..c185d0db7 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -84,7 +84,9 @@ class PlaybackPanelFragment : } binding.playbackToolbar.apply { - setNavigationOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Collapse) } + setNavigationOnClickListener { + navModel.mainNavigateTo(MainNavigationAction.ClosePlaybackPanel) + } setOnMenuItemClickListener(this@PlaybackPanelFragment) } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt index 116f57013..17d5b9d23 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt @@ -128,10 +128,10 @@ class NavigationViewModel : ViewModel() { */ sealed class MainNavigationAction { /** Expand the playback panel. */ - object Expand : MainNavigationAction() + object OpenPlaybackPanel : MainNavigationAction() /** Collapse the playback bottom sheet. */ - object Collapse : MainNavigationAction() + object ClosePlaybackPanel : MainNavigationAction() /** * Navigate to the given [NavDirections]. From 5de1e221acd03effce8fe8f09e94b1f92ba980a4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 25 Mar 2023 15:54:06 -0600 Subject: [PATCH 19/88] widget: fix inconsistent cover corner radius Somehow the line that makes the corner sizes consistent was lost. --- .../main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 6069beed8..8bc813c48 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -95,7 +95,9 @@ constructor( return if (cornerRadius > 0) { // If rounded, reduce the bitmap size further to obtain more pronounced // rounded corners. - builder.transformations( + builder + .size(getSafeRemoteViewsImageSize(context, 10f)) + .transformations( SquareFrameTransform.INSTANCE, RoundedCornersTransformation(cornerRadius.toFloat())) } else { From f2a90bf0af3f9b3829dcd24d9b8fc33b2fe8d889 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 25 Mar 2023 21:01:21 -0600 Subject: [PATCH 20/88] picker: refactor into module-specific impls Refactor the weird picker god module into specific sub-impls in playback and a new navigation package. I cannot keep this unified. The needs are too different among each picker. Better to keep it separate, especially in preparation for the playlist dialogs. --- CHANGELOG.md | 3 + .../java/org/oxycblt/auxio/MainActivity.kt | 3 + .../java/org/oxycblt/auxio/MainFragment.kt | 4 +- .../auxio/detail/AlbumDetailFragment.kt | 2 +- .../auxio/detail/ArtistDetailFragment.kt | 2 +- .../auxio/detail/GenreDetailFragment.kt | 2 +- .../auxio/detail/PlaylistDetailFragment.kt | 2 +- .../oxycblt/auxio/detail/SongDetailDialog.kt | 2 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 4 +- .../auxio/home/list/AlbumListFragment.kt | 2 +- .../auxio/home/list/ArtistListFragment.kt | 2 +- .../auxio/home/list/GenreListFragment.kt | 2 +- .../auxio/home/list/PlaylistListFragment.kt | 2 +- .../auxio/home/list/SongListFragment.kt | 2 +- .../org/oxycblt/auxio/list/ListFragment.kt | 4 +- .../auxio/navigation/MainNavigationAction.kt | 44 +++++++++ .../{ui => navigation}/NavigationViewModel.kt | 34 ++----- .../picker/ArtistNavigationPickerDialog.kt | 27 ++++-- .../picker/NavigationPickerViewModel.kt | 93 ++++++++++++++++++ .../auxio/picker/ArtistChoiceAdapter.kt | 90 ----------------- .../org/oxycblt/auxio/picker/ChoiceAdapter.kt | 87 +++++++++++++++++ .../auxio/picker/GenreChoiceAdapter.kt | 90 ----------------- .../org/oxycblt/auxio/picker/PickerChoices.kt | 31 ++++++ ...ickerDialog.kt => PickerDialogFragment.kt} | 48 ++++++---- .../oxycblt/auxio/picker/PickerViewModel.kt | 87 ----------------- .../auxio/playback/PlaybackBarFragment.kt | 4 +- .../auxio/playback/PlaybackPanelFragment.kt | 4 +- .../picker/ArtistPlaybackPickerDialog.kt | 27 ++++-- .../picker/GenrePlaybackPickerDialog.kt | 52 +++------- .../picker/PlaybackPickerViewModel.kt | 96 +++++++++++++++++++ .../oxycblt/auxio/search/SearchFragment.kt | 2 +- .../oxycblt/auxio/widgets/WidgetComponent.kt | 4 +- app/src/main/res/navigation/nav_main.xml | 14 +-- 33 files changed, 472 insertions(+), 400 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt rename app/src/main/java/org/oxycblt/auxio/{ui => navigation}/NavigationViewModel.kt (83%) rename app/src/main/java/org/oxycblt/auxio/{ => navigation}/picker/ArtistNavigationPickerDialog.kt (66%) create mode 100644 app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/picker/ArtistChoiceAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/picker/GenreChoiceAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/picker/PickerChoices.kt rename app/src/main/java/org/oxycblt/auxio/picker/{ArtistPickerDialog.kt => PickerDialogFragment.kt} (59%) delete mode 100644 app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt rename app/src/main/java/org/oxycblt/auxio/{ => playback}/picker/ArtistPlaybackPickerDialog.kt (70%) rename app/src/main/java/org/oxycblt/auxio/{ => playback}/picker/GenrePlaybackPickerDialog.kt (50%) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b18f594f3..dad347f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## dev +## What's Fixed +- Fixed inconsistent corner radius on widget cover art + ## What's Improved - Added ability to click on the playback bar to exit the queue view diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 1fb9733f2..410ea7904 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -48,6 +48,9 @@ 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: Use sealed interface where applicable + * TODO: Fix UID naming + * TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims) */ @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 8ce51e915..817ef494e 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -41,11 +41,11 @@ import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Music 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.* 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 e253f0752..0e81847ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -44,8 +44,8 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent 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.* /** 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 ed3ff8f13..7e7b70816 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -43,8 +43,8 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent 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.* /** 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 ebcc60a02..3335e6068 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -39,8 +39,8 @@ import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort 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.ui.NavigationViewModel import org.oxycblt.auxio.util.* /** diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index f0e8e64ba..635574c13 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -39,8 +39,8 @@ import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.Sort 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.ui.NavigationViewModel import org.oxycblt.auxio.util.* /** 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..8de46fb72 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -66,7 +66,7 @@ class SongDetailDialog : ViewBindingDialogFragment() { super.onBindingCreated(binding, savedInstanceState) binding.detailProperties.adapter = detailAdapter // DetailViewModel handles most initialization from the navigation argument. - detailModel.setSongUid(args.itemUid) + detailModel.setSongUid(args.songUid) collectImmediately(detailModel.currentSong, detailModel.songAudioInfo, ::updateSong) } 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 0827a00b9..291340b76 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -52,9 +52,9 @@ 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.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.* /** 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..2266f56a1 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 /** 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 29e490dc6..8b3a5c369 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,9 @@ 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.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 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 f9b1f9aba..b6ba1d9cf 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,9 @@ 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.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 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 index 12d21c7c9..c8df535f2 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -37,9 +37,9 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent 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.ui.NavigationViewModel import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD 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..d24f6abab 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 @@ -40,10 +40,10 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent 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 /** 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 c30909c33..d9c48151a 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -29,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 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..36eec45f7 --- /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 class 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 83% 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 17d5b9d23..27125123f 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() /** @@ -118,26 +123,3 @@ class NavigationViewModel : ViewModel() { } } } - -/** - * 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 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/picker/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationPickerDialog.kt similarity index 66% rename from app/src/main/java/org/oxycblt/auxio/picker/ArtistNavigationPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationPickerDialog.kt index a43fc61a0..24d49a8cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/ArtistNavigationPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationPickerDialog.kt @@ -16,32 +16,41 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.picker +package org.oxycblt.auxio.navigation.picker -import android.os.Bundle import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint -import org.oxycblt.auxio.databinding.DialogMusicPickerBinding +import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.ui.NavigationViewModel +import org.oxycblt.auxio.navigation.NavigationViewModel +import org.oxycblt.auxio.picker.PickerChoices +import org.oxycblt.auxio.picker.PickerDialogFragment /** - * An [ArtistPickerDialog] intended for when [Artist] navigation is ambiguous. + * A [PickerDialogFragment] intended for when [Artist] navigation is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class ArtistNavigationPickerDialog : ArtistPickerDialog() { +class ArtistNavigationPickerDialog : PickerDialogFragment() { private val navModel: NavigationViewModel by activityViewModels() + private val pickerModel: NavigationPickerViewModel 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: ArtistNavigationPickerDialogArgs by navArgs() - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { - pickerModel.setItemUid(args.itemUid) - super.onBindingCreated(binding, savedInstanceState) + override val titleRes: Int + get() = R.string.lbl_artists + + override val pickerChoices: StateFlow?> + get() = pickerModel.currentArtistChoices + + override fun initChoices() { + pickerModel.setArtistChoiceUid(args.artistUid) } override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { 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..dc14b8ea0 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt @@ -0,0 +1,93 @@ +/* + * 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.* +import org.oxycblt.auxio.picker.PickerChoices + +/** + * A [ViewModel] that stores the current information required for [ArtistNavigationPickerDialog]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@HiltViewModel +class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : + ViewModel(), MusicRepository.UpdateListener { + private val _currentArtistChoices = MutableStateFlow(null) + /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ + val currentArtistChoices: StateFlow?> + get() = _currentArtistChoices + + 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. + _currentArtistChoices.value = + when (val choices = _currentArtistChoices.value) { + is ArtistNavigationChoices.FromSong -> + deviceLibrary.findSong(choices.song.uid)?.let { + ArtistNavigationChoices.FromSong(it) + } + is ArtistNavigationChoices.FromAlbum -> + deviceLibrary.findAlbum(choices.album.uid)?.let { + ArtistNavigationChoices.FromAlbum(it) + } + else -> null + } + } + + override fun onCleared() { + super.onCleared() + musicRepository.removeUpdateListener(this) + } + + /** + * Set the [Music.UID] of the item to show artist choices for. + * + * @param uid The [Music.UID] of the item to show. Must be a [Song] or [Album]. + */ + fun setArtistChoiceUid(uid: Music.UID) { + // Support Songs and Albums, which have parent artists. + _currentArtistChoices.value = + when (val music = musicRepository.find(uid)) { + is Song -> ArtistNavigationChoices.FromSong(music) + is Album -> ArtistNavigationChoices.FromAlbum(music) + else -> null + } + } + + private sealed interface ArtistNavigationChoices : PickerChoices { + data class FromSong(val song: Song) : ArtistNavigationChoices { + override val choices = song.artists + } + + data class FromAlbum(val album: Album) : ArtistNavigationChoices { + override val choices = album.artists + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/ArtistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/picker/ArtistChoiceAdapter.kt deleted file mode 100644 index 5c9ad275b..000000000 --- a/app/src/main/java/org/oxycblt/auxio/picker/ArtistChoiceAdapter.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * ArtistChoiceAdapter.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.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.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. - * - * @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 - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - ArtistChoiceViewHolder.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() - } - } -} - -/** - * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Artist] item, for - * use with [ArtistChoiceAdapter]. Use [from] to create an instance. - */ -class ArtistChoiceViewHolder(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.resolveName(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) = - ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt new file mode 100644 index 000000000..31cd2c085 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023 Auxio Project + * ChoiceAdapter.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.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.* +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.inflater + +/** A [RecyclerView.Adapter] that shows a list */ +class ChoiceAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter>(ChoiceViewHolder.diffCallback()) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ChoiceViewHolder.from(parent) + + override fun onBindViewHolder(holder: ChoiceViewHolder, position: Int) = + holder.bind(getItem(position), listener) +} + +/** + * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [T] item, for use + * with [ChoiceAdapter]. Use [from] to create an instance. + */ +class ChoiceViewHolder +private constructor(private val binding: ItemPickerChoiceBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + /** + * Bind new data to this instance. + * + * @param music The new [T] to bind. + * @param listener A [ClickableListListener] to bind interactions to. + */ + fun bind(music: T, listener: ClickableListListener) { + listener.bind(music, this) + // ImageGroup is not generic, so we must downcast to specific types for now. + when (music) { + is Song -> binding.pickerImage.bind(music) + is Album -> binding.pickerImage.bind(music) + is Artist -> binding.pickerImage.bind(music) + is Genre -> binding.pickerImage.bind(music) + is Playlist -> binding.pickerImage.bind(music) + } + binding.pickerName.text = music.resolveName(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) = + ChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) + + /** Get a comparator that can be used with DiffUtil. */ + fun diffCallback() = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: T, newItem: T) = + oldItem.rawName == newItem.rawName + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/GenreChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/picker/GenreChoiceAdapter.kt deleted file mode 100644 index b01b42928..000000000 --- a/app/src/main/java/org/oxycblt/auxio/picker/GenreChoiceAdapter.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * GenreChoiceAdapter.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.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.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. - * - * @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 - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - GenreChoiceViewHolder.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() - } - } -} - -/** - * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Genre] item, for - * use with [GenreChoiceAdapter]. Use [from] to create an instance. - */ -class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : - DialogRecyclerView.ViewHolder(binding.root) { - /** - * Bind new data to this instance. - * - * @param genre 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) - } - - companion object { - /** - * Create a new instance. - * - * @param parent The parent to inflate this instance from. - * @return A new instance. - */ - fun from(parent: View) = - GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/PickerChoices.kt b/app/src/main/java/org/oxycblt/auxio/picker/PickerChoices.kt new file mode 100644 index 000000000..b2de58fd5 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/picker/PickerChoices.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 Auxio Project + * PickerChoices.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 org.oxycblt.auxio.music.Music + +/** + * Represents a list of [Music] to show in a picker UI. + * + * @author Alexander Capehart (OxygenCobalt) + */ +interface PickerChoices { + /** The list of choices to show. */ + val choices: List +} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/ArtistPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/picker/PickerDialogFragment.kt similarity index 59% rename from app/src/main/java/org/oxycblt/auxio/picker/ArtistPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/picker/PickerDialogFragment.kt index bf8e48265..abbf498fe 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/ArtistPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/PickerDialogFragment.kt @@ -1,6 +1,6 @@ /* - * Copyright (c) 2022 Auxio Project - * ArtistPickerDialog.kt is part of Auxio. + * Copyright (c) 2023 Auxio Project + * PickerDialogFragment.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 @@ -21,45 +21,53 @@ 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 kotlinx.coroutines.flow.StateFlow 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.list.adapter.UpdateInstructions +import org.oxycblt.auxio.music.Music 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. + * A [ViewBindingDialogFragment] that acts as the base for a "picker" UI, shown when a given choice + * is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ -@AndroidEntryPoint -abstract class ArtistPickerDialog : - ViewBindingDialogFragment(), ClickableListListener { - protected val pickerModel: PickerViewModel by viewModels() +abstract class PickerDialogFragment : + ViewBindingDialogFragment(), ClickableListListener { // Okay to leak this since the Listener will not be called until after initialization. - private val artistAdapter = ArtistChoiceAdapter(@Suppress("LeakingThis") this) + private val choiceAdapter = ChoiceAdapter(@Suppress("LeakingThis") this) + + /** The string resource to use in the dialog title. */ + abstract val titleRes: Int + /** The [StateFlow] of choices to show in the picker. */ + abstract val pickerChoices: StateFlow?> + /** Called when the choice list should be initialized from the stored arguments. */ + abstract fun initChoices() 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) + builder.setTitle(titleRes).setNegativeButton(R.string.lbl_cancel, null) } override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { - binding.pickerRecycler.adapter = artistAdapter + binding.pickerRecycler.apply { + itemAnimator = null + adapter = choiceAdapter + } - collectImmediately(pickerModel.artistChoices) { artists -> - if (artists.isNotEmpty()) { - // Make sure the artist choices align with any changes in the music library. - artistAdapter.submitList(artists) + initChoices() + collectImmediately(pickerChoices) { item -> + if (item != null) { + // Make sure the choices align with any changes in the music library. + choiceAdapter.update(item.choices, UpdateInstructions.Diff) } else { // Not showing any choices, navigate up. findNavController().navigateUp() @@ -71,7 +79,7 @@ abstract class ArtistPickerDialog : binding.pickerRecycler.adapter = null } - override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) { findNavController().navigateUp() } } 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 0ddb37953..000000000 --- a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt +++ /dev/null @@ -1,87 +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.* - -/** - * 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.UpdateListener { - - 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 - - init { - musicRepository.addUpdateListener(this) - } - - override fun onCleared() { - musicRepository.removeUpdateListener(this) - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - if (changes.deviceLibrary && musicRepository.deviceLibrary != 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) { - _currentItem.value = musicRepository.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 29c0d889e..b33a973ff 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 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 c185d0db7..f76a5da9a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -36,10 +36,10 @@ import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.music.MusicParent 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 diff --git a/app/src/main/java/org/oxycblt/auxio/picker/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackPickerDialog.kt similarity index 70% rename from app/src/main/java/org/oxycblt/auxio/picker/ArtistPlaybackPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackPickerDialog.kt index 36a73aa81..ff8dbabfa 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/ArtistPlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackPickerDialog.kt @@ -16,18 +16,19 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.picker +package org.oxycblt.auxio.playback.picker -import android.os.Bundle import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint -import org.oxycblt.auxio.databinding.DialogMusicPickerBinding +import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.picker.PickerChoices +import org.oxycblt.auxio.picker.PickerDialogFragment import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.requireIs import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -36,21 +37,27 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class ArtistPlaybackPickerDialog : ArtistPickerDialog() { +class ArtistPlaybackPickerDialog : PickerDialogFragment() { 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: ArtistPlaybackPickerDialogArgs by navArgs() - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { - pickerModel.setItemUid(args.itemUid) - super.onBindingCreated(binding, savedInstanceState) + override val titleRes: Int + get() = R.string.lbl_artists + + override val pickerChoices: StateFlow?> + get() = pickerModel.currentArtistChoices + + override fun initChoices() { + pickerModel.setArtistChoiceUid(args.artistUid) } 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)) + val song = unlikelyToBeNull(pickerModel.currentArtistChoices.value).song playbackModel.playFromArtist(song, item) } } diff --git a/app/src/main/java/org/oxycblt/auxio/picker/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackPickerDialog.kt similarity index 50% rename from app/src/main/java/org/oxycblt/auxio/picker/GenrePlaybackPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackPickerDialog.kt index 17441f9a9..e4af11d41 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/GenrePlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackPickerDialog.kt @@ -16,26 +16,20 @@ * 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 -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 kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.DialogMusicPickerBinding -import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.picker.PickerChoices +import org.oxycblt.auxio.picker.PickerDialogFragment 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 +38,27 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class GenrePlaybackPickerDialog : - ViewBindingDialogFragment(), ClickableListListener { - private val pickerModel: PickerViewModel by viewModels() +class GenrePlaybackPickerDialog : PickerDialogFragment() { 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) + override val titleRes: Int + get() = R.string.lbl_genres - override fun onConfigDialog(builder: AlertDialog.Builder) { - builder.setTitle(R.string.lbl_genres).setNegativeButton(R.string.lbl_cancel, null) - } + override val pickerChoices: StateFlow?> + get() = pickerModel.currentGenreChoices - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { - binding.pickerRecycler.adapter = genreAdapter - - 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) - } else { - // Not showing any choices, navigate up. - findNavController().navigateUp() - } - } - } - - override fun onDestroyBinding(binding: DialogMusicPickerBinding) { - binding.pickerRecycler.adapter = null + override fun initChoices() { + pickerModel.setGenreChoiceUid(args.genreUid) } override fun onClick(item: Genre, viewHolder: RecyclerView.ViewHolder) { + super.onClick(item, viewHolder) // User made a choice, play the given song from that genre. - val song = requireIs(unlikelyToBeNull(pickerModel.currentItem.value)) + val song = unlikelyToBeNull(pickerModel.currentGenreChoices.value).song playbackModel.playFromGenre(song, item) } } 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..2fcd20e13 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt @@ -0,0 +1,96 @@ +/* + * 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.* +import org.oxycblt.auxio.picker.PickerChoices + +/** + * 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 _currentArtistChoices = MutableStateFlow(null) + /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ + val currentArtistChoices: StateFlow + get() = _currentArtistChoices + + private val _currentGenreChoices = MutableStateFlow(null) + /** The current set of [Genre] choices to show in the picker, or null if to show nothing. */ + val currentGenreChoices: StateFlow + get() = _currentGenreChoices + + init { + musicRepository.addUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.deviceLibrary) return + val deviceLibrary = musicRepository.deviceLibrary ?: return + _currentArtistChoices.value = + _currentArtistChoices.value?.run { + deviceLibrary.findSong(song.uid)?.let { newSong -> ArtistPlaybackChoices(newSong) } + } + _currentGenreChoices.value = + _currentGenreChoices.value?.run { + deviceLibrary.findSong(song.uid)?.let { newSong -> GenrePlaybackChoices(newSong) } + } + } + + override fun onCleared() { + super.onCleared() + musicRepository.removeUpdateListener(this) + } + + /** + * Set the [Music.UID] of the item to show [Artist] choices for. + * + * @param uid The [Music.UID] of the item to show. Must be a [Song]. + */ + fun setArtistChoiceUid(uid: Music.UID) { + _currentArtistChoices.value = + musicRepository.deviceLibrary?.findSong(uid)?.let { ArtistPlaybackChoices(it) } + } + + /** + * Set the [Music.UID] of the item to show [Genre] choices for. + * + * @param uid The [Music.UID] of the item to show. Must be a [Song]. + */ + fun setGenreChoiceUid(uid: Music.UID) { + _currentGenreChoices.value = + musicRepository.deviceLibrary?.findSong(uid)?.let { GenrePlaybackChoices(it) } + } +} + +data class ArtistPlaybackChoices(val song: Song) : PickerChoices { + override val choices = song.artists +} + +data class GenrePlaybackChoices(val song: Song) : PickerChoices { + override val choices = song.genres +} 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 b7b693a85..6bf06014d 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -37,8 +37,8 @@ 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.ui.NavigationViewModel import org.oxycblt.auxio.util.* /** diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 8bc813c48..6c1e9e91b 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -98,8 +98,8 @@ constructor( builder .size(getSafeRemoteViewsImageSize(context, 10f)) .transformations( - SquareFrameTransform.INSTANCE, - RoundedCornersTransformation(cornerRadius.toFloat())) + SquareFrameTransform.INSTANCE, + RoundedCornersTransformation(cornerRadius.toFloat())) } else { builder.size(getSafeRemoteViewsImageSize(context)) } diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 69a13a74c..22d5f7343 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -30,31 +30,31 @@ @@ -64,7 +64,7 @@ android:label="song_detail_dialog" tools:layout="@layout/dialog_song_detail"> From 2e34933c86be134b658dacaf2afbb4b60353fd88 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Tue, 11 Apr 2023 15:49:58 +0200 Subject: [PATCH 21/88] Translations update from Hosted Weblate (#403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ * Translated using Weblate (Czech) Currently translated at 100.0% (260 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (German) Currently translated at 100.0% (260 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Russian) Currently translated at 100.0% (260 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (260 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (260 of 260 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Czech) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (German) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Spanish) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Korean) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Russian) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Added translation using Weblate (Odia) * Added translation using Weblate (Finnish) * Translated using Weblate (Odia) Currently translated at 0.7% (2 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/or/ * Translated using Weblate (Greek) Currently translated at 45.5% (119 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/el/ * Translated using Weblate (Italian) Currently translated at 99.6% (260 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/ * Translated using Weblate (Polish) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Croatian) Currently translated at 100.0% (30 of 30 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hr/ * Translated using Weblate (Croatian) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/ * Translated using Weblate (Odia) Currently translated at 1.9% (5 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/or/ * Translated using Weblate (Finnish) Currently translated at 90.0% (235 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/ --------- Co-authored-by: Fjuro Co-authored-by: qwerty287 Co-authored-by: Макар Разин Co-authored-by: Vaclovas Intas Co-authored-by: gallegonovato Co-authored-by: Hoseok Seo Co-authored-by: BMN Co-authored-by: Eric Co-authored-by: Jiri Grönroos Co-authored-by: Subham Jena Co-authored-by: Ricardo Feleque Co-authored-by: atilluF Co-authored-by: Maciej Klupp Co-authored-by: Milo Ivir --- app/src/main/res/values-be/strings.xml | 2 + app/src/main/res/values-cs/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-el/strings.xml | 21 +- app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fi/strings.xml | 247 ++++++++++++++++++ app/src/main/res/values-fr/strings.xml | 2 - app/src/main/res/values-hr/strings.xml | 8 +- app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values-lt/strings.xml | 5 + app/src/main/res/values-or/strings.xml | 8 + app/src/main/res/values-pl/strings.xml | 12 +- app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values-uk/strings.xml | 3 +- app/src/main/res/values-zh-rCN/strings.xml | 1 + .../metadata/android/hr/full_description.txt | 6 +- 17 files changed, 305 insertions(+), 21 deletions(-) create mode 100644 app/src/main/res/values-fi/strings.xml create mode 100644 app/src/main/res/values-or/strings.xml diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 8adbde3f8..0c6f1b845 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -274,4 +274,6 @@ Вокладка плэйліст для %s Плэйліст Плэйлісты + Адкл. + Стварыце новы плэйліст \ 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 8150345a1..83068d439 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -285,4 +285,6 @@ 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ě) + Žádné + Vytvořit nový playlist \ 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 39e481ff4..9b4f49f1c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -276,4 +276,6 @@ Wiedergabelisten Artikel beim Sortieren ignorieren Wörter wie „the“ ignorieren (funktioniert am besten mit englischsprachiger Musik) + Keine + Neue Wiedergabeliste erstellen \ 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 24a1aa723..0dc0c61bf 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -281,4 +281,5 @@ Ninguno 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 \ 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..60578993e --- /dev/null +++ b/app/src/main/res/values-fi/strings.xml @@ -0,0 +1,247 @@ + + + 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 + Ei mitään + 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 4baf5897e..5dc75c8d8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -177,9 +177,7 @@ Musique Images Qualité améliorée (chargement lent) - Ignorez les mots comme \"the\" lors du tri par nom (fonctionne mieux avec la musique en anglais) Configurer les caractères qui indiquent plusieurs valeurs de balise - Ignorer les articles lors du tri 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) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index e52d30b9e..b183da9dc 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -264,11 +264,13 @@ Reprodukcija Fonoteka Status reprodukcije - Zanemarite članke prilikom sortiranja Popisi pjesama Popis pjesama Glazba - Ignorirajte riječi poput \"the\" prilikom sortiranja po imenu (najbolje radi s glazbom na engleskom jeziku) - Slika popisa za reprodukciju za %s + Slika popisa pjesama za %s Ponašanje + Ništa + 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 \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 16b583129..1b43c942f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -279,4 +279,6 @@ 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 \ 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 e5a38142f..4437eb3b0 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -276,4 +276,6 @@ %s의 재생 목록 이미지 정렬할 때 기사 무시 이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함) + 없음 + 새 재생 목록 만들기 \ 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..1fa1c648f 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -271,4 +271,9 @@ 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 + Jokios + Sukurti naują grojaraštį \ No newline at end of file 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-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 96183c8a1..65c74d5ac 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -136,7 +136,7 @@ Odtwórz album Automatycznie odtwórz muzykę po podłączeniu słuchawek (może nie działać na wszystkich urządzeniach) Odśwież muzykę - Odśwież bibliotekę muzyczną używając tagów z pamięci cache, jeśli to możliwe + 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 Automatycznie odśwież @@ -277,8 +277,10 @@ Nie można zapisać stanu odtwarzania Malejąco Playlisty - Playlist - Obraz playlisty dla %s - 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) + 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) + Brak + Utwórz nową playlistę \ 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 c998542bc..5d0db7784 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -283,4 +283,6 @@ Обложка плейлиста для %s Игнорировать артикли при сортировке Игнорировать такие слова, как «the», при сортировке по имени (лучше всего работает с англоязычной музыкой) + Откл. + Создать новый плейлист \ 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 fba72e2c8..59b1a49be 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -279,6 +279,7 @@ Список відтворення Списки відтворення Немає - Ігнорувати артиклі під час сортування + Інтелектуальне сортування Ігнорування таких слів, як \"the\", під час сортування за назвою (найкраще працює з англомовною музикою) + Створити новий список відтворення \ 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 4242bbee6..db6995078 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -275,4 +275,5 @@ 排序时忽略冠词 按名称排序时忽略类似“the”这样的冠词(对英文歌曲的效果最好) + 创建新的播放列表 \ No newline at end of file diff --git a/fastlane/metadata/android/hr/full_description.txt b/fastlane/metadata/android/hr/full_description.txt index 86158228f..d928d803c 100644 --- a/fastlane/metadata/android/hr/full_description.txt +++ b/fastlane/metadata/android/hr/full_description.txt @@ -1,10 +1,10 @@ -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 +- Brzo korisničko sučelje u skladu s najnovijim Materijal dizajnom +- Korisničko iskustvo koje priorizira jednostavnost 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 From 7d8cdba6a9ce0e68c6a13e96d2c009022ff49fda Mon Sep 17 00:00:00 2001 From: Chris Palmeri Date: Wed, 12 Apr 2023 00:26:24 -0500 Subject: [PATCH 22/88] Regex numbers and common thumb --- .../java/org/oxycblt/auxio/music/Music.kt | 53 +++++++------------ 1 file changed, 20 insertions(+), 33 deletions(-) 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 93fc7e581..2b77da6b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -349,7 +349,6 @@ interface Playlist : MusicParent { * @author Alexander Capehart (OxygenCobalt) */ class SortName(name: String, musicSettings: MusicSettings) : Comparable { - private val number: Int? private val collationKey: CollationKey val thumbString: String? @@ -366,50 +365,38 @@ class SortName(name: String, musicSettings: MusicSettings) : Comparable number = null - // Whole title is numeric. - -1 -> { - number = sortName.toIntOrNull() - sortName = "" + // TODO: replace starting ' " ... ( + + // Zero pad the first number to (an arbitrary) five digits for better sorting + // Will also accept commas in between digits and strip them + sortName = + sortName.replace("""(\d+[\d,]+\d+|\d+)(.*)""".toRegex()) { + val (firstNumber, remainingText) = it.destructured + val onlyDigits = firstNumber.filter { c -> c.isDigit() } + onlyDigits.padStart(5, '0') + remainingText } - // Part of the title is numeric. - else -> { - number = sortName.slice(0 until numericEnd).toIntOrNull() - sortName = sortName.slice(numericEnd until sortName.length) - } - } - } else { - number = null } 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 = (number?.toString() ?: collationKey?.run { sourceString.first().uppercase() }) + thumbString = + collationKey?.run { + var thumbChar = sourceString.firstOrNull() + if (thumbChar?.isLetter() != true) thumbChar = '#' + thumbChar.uppercase() + } } - override fun toString(): String = number?.toString() ?: collationKey.sourceString + override fun toString(): String = collationKey.sourceString - override fun compareTo(other: SortName) = - when { - number != null && other.number != null -> number.compareTo(other.number) - number != null && other.number == null -> -1 // a < b - number == null && other.number != null -> 1 // a > b - else -> collationKey.compareTo(other.collationKey) - } + override fun compareTo(other: SortName) = collationKey.compareTo(other.collationKey) - override fun equals(other: Any?) = - other is SortName && number == other.number && collationKey == other.collationKey + override fun equals(other: Any?) = other is SortName && collationKey == other.collationKey - override fun hashCode(): Int { - var hashCode = collationKey.hashCode() - if (number != null) hashCode = 31 * hashCode + number - return hashCode - } + override fun hashCode(): Int = collationKey.hashCode() private companion object { val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY } From b34462340a76746e943c150403d9ae20fd55a8f1 Mon Sep 17 00:00:00 2001 From: Chris Palmeri Date: Wed, 12 Apr 2023 23:33:16 -0500 Subject: [PATCH 23/88] Strip symbols and forget thousands separator --- .../main/java/org/oxycblt/auxio/music/Music.kt | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) 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 2b77da6b5..9b5abb36f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -355,6 +355,9 @@ class SortName(name: String, musicSettings: MusicSettings) : Comparable c.isDigit() } - onlyDigits.padStart(5, '0') + remainingText - } + // Zero pad all numbers to (an arbitrary) five digits for better sorting + sortName = sortName.replace(Regex("""\d+""")) { it.value.padStart(5, '0') } } collationKey = COLLATOR.getCollationKey(sortName) From d04cd4ce4f7ef5775f1e4966094804b0fbeec085 Mon Sep 17 00:00:00 2001 From: Chris Palmeri Date: Sat, 15 Apr 2023 10:05:15 -0500 Subject: [PATCH 24/88] Refactor sort name changes --- app/src/main/java/org/oxycblt/auxio/music/Music.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 9b5abb36f..49cae357f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -355,8 +355,7 @@ class SortName(name: String, musicSettings: MusicSettings) : Comparable Date: Sat, 15 Apr 2023 10:11:39 -0500 Subject: [PATCH 25/88] Use six digit sort name padding --- app/src/main/java/org/oxycblt/auxio/music/Music.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 49cae357f..12b69191e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -367,8 +367,8 @@ class SortName(name: String, musicSettings: MusicSettings) : Comparable Date: Wed, 10 May 2023 14:00:36 -0600 Subject: [PATCH 26/88] build: update deps Time to catch up on dependency updates after working non-stop for a month and a half. --- app/build.gradle | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 69e5a79a7..6384bbcbb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -83,18 +83,18 @@ dependencies { // 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 +111,7 @@ dependencies { implementation "androidx.preference:preference-ktx:1.2.0" // Database - def room_version = '2.5.0' + def room_version = '2.5.1' implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" @@ -126,7 +126,8 @@ dependencies { implementation 'io.coil-kt:coil-base:2.2.2' // 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" From c1e4d0f10ec5cfb27a4048828b600998b204f766 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 10 May 2023 17:47:31 -0600 Subject: [PATCH 27/88] all: switch to media3 Move everything over to the media3 library instead of ExoPlayer. Media3 is worse in every way. It labels half of ExoPlayer as "unsafe" because it thinks that it's garbage uwu "helpful" abstractions are perfectly servicable when in reality they are a pile of garbage filled with insane performance issues, race conditions, and a seeming lack of awareness to the sheer absurdity of android's media APIs. It is absolutely horrible, but ExoPlayer will stop being maintained soon and I will have to move over for further maintenance. --- .gitmodules | 7 +- CHANGELOG.md | 8 +++ ExoPlayer | 1 - app/build.gradle | 4 +- .../org/oxycblt/auxio/image/ImageModule.kt | 36 ---------- .../auxio/image/extractor/CoverExtractor.kt | 12 ++-- .../auxio/image/extractor/ExtractorModule.kt | 59 +++++++++++++++ .../auxio/music/metadata/TagExtractor.kt | 2 +- .../oxycblt/auxio/music/metadata/TagWorker.kt | 8 +-- .../oxycblt/auxio/music/metadata/TextTags.kt | 8 +-- .../oxycblt/auxio/playback/PlaybackModule.kt | 48 ------------- .../replaygain/ReplayGainAudioProcessor.kt | 12 ++-- .../auxio/playback/system/PlaybackService.kt | 20 +++--- .../auxio/playback/system/SystemModule.kt | 71 +++++++++++++++++++ media | 1 + settings.gradle | 4 +- 16 files changed, 175 insertions(+), 126 deletions(-) delete mode 160000 ExoPlayer create mode 100644 app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/system/SystemModule.kt create mode 160000 media 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 59f777a97..53b3d8295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## dev + +#### What's Fixed +- Fixed issue where vorbis comments in the form of `metadata_block_picture` (lowercase) would not +be parsed as images + +## 3.0.5 + #### What's Fixed - Fixed inconsistent corner radius on widget - Fixed crash that would occur due to intelligent sort name functionality 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/app/build.gradle b/app/build.gradle index 8870a165b..7f77e603d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -119,8 +119,8 @@ 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' 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 c73af2c73..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,33 +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, - playlistFactory: PlaylistImageFetcher.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) - add(playlistFactory) - } - // 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/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index 1c2bb113d..83bd50fd5 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 @@ -20,12 +20,12 @@ package org.oxycblt.auxio.image.extractor import android.content.Context 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 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 dagger.hilt.android.qualifiers.ApplicationContext import java.io.ByteArrayInputStream import java.io.InputStream 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..91adba89e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt @@ -0,0 +1,59 @@ +/* + * 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, + songFactory: AlbumCoverFetcher.SongFactory, + albumFactory: AlbumCoverFetcher.AlbumFactory, + artistFactory: ArtistImageFetcher.Factory, + genreFactory: GenreImageFetcher.Factory, + playlistFactory: PlaylistImageFetcher.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) + add(playlistFactory) + } + // 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/music/metadata/TagExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt index 60586d9ee..7e31400ff 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,7 +18,7 @@ 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 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 1a553211c..2a096c789 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,10 +19,10 @@ 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.device.RawSong diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt index 9b486c623..cecd572ba 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt @@ -18,10 +18,10 @@ package org.oxycblt.auxio.music.metadata -import com.google.android.exoplayer2.metadata.Metadata -import com.google.android.exoplayer2.metadata.id3.InternalFrame -import com.google.android.exoplayer2.metadata.id3.TextInformationFrame -import com.google.android.exoplayer2.metadata.vorbis.VorbisComment +import androidx.media3.common.Metadata +import androidx.media3.extractor.metadata.id3.InternalFrame +import androidx.media3.extractor.metadata.id3.TextInformationFrame +import androidx.media3.extractor.metadata.vorbis.VorbisComment /** * Processing wrapper for [Metadata] that allows organized access to text-based audio tags. 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/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index e9015c8d5..fafe5266c 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 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 a03f231fd..e6ae6a8d0 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 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/media b/media new file mode 160000 index 000000000..e01f3be06 --- /dev/null +++ b/media @@ -0,0 +1 @@ +Subproject commit e01f3be069d30d933f3812cf3b51ece791d67510 diff --git a/settings.gradle b/settings.gradle index df2dcbd45..4595c06e6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,4 @@ 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-' +apply from: file("media/core_settings.gradle") \ No newline at end of file From 3daaadf8eaf39f1c1c5b2f5ee6a6397893f2aac6 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 10 May 2023 17:50:58 -0600 Subject: [PATCH 28/88] all: disable media3 optin insanity Forgot to do this in the prior commit. Again, nearly every use of ExoPlayer by Auxio is considered "unsafe" and would fail the build unless I slather it all in OptIn annotations or have a global lint.xml file just sitting in the root directory. --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index e01f3be06..5346fe2e5 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit e01f3be069d30d933f3812cf3b51ece791d67510 +Subproject commit 5346fe2e5c812756465e5cb255f388b0db5cf017 From ca349dea185f3b4073b5b4ead32914ee59e75b1e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 10 May 2023 20:48:44 -0600 Subject: [PATCH 29/88] music: fix tests Fix tests that weren't migated to media3. --- .../org/oxycblt/auxio/music/metadata/TextTagsTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 From c7b875376c168d5ee30fec19e1a6328998a73401 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 12:16:26 -0600 Subject: [PATCH 30/88] music: refactor name implementation Refactor the music name implementation to do the following: 1. Unify normal and sort names under a single datatype 2. Handle arbitrary-length digit strings 3. Ignore puncutation regardless of the intelligent sort configuration, as it is trivially localizable. Resolves #423. Co-authored by: ChatGPT-3.5 --- .../auxio/detail/AlbumDetailFragment.kt | 2 +- .../auxio/detail/ArtistDetailFragment.kt | 2 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 22 +- .../auxio/detail/GenreDetailFragment.kt | 2 +- .../auxio/detail/PlaylistDetailFragment.kt | 2 +- .../oxycblt/auxio/detail/SongDetailDialog.kt | 17 +- .../detail/header/AlbumDetailHeaderAdapter.kt | 2 +- .../header/ArtistDetailHeaderAdapter.kt | 2 +- .../detail/header/GenreDetailHeaderAdapter.kt | 2 +- .../header/PlaylistDetailHeaderAdapter.kt | 2 +- .../detail/list/AlbumDetailListAdapter.kt | 6 +- .../detail/list/ArtistDetailListAdapter.kt | 11 +- .../auxio/home/list/AlbumListFragment.kt | 4 +- .../auxio/home/list/ArtistListFragment.kt | 2 +- .../auxio/home/list/GenreListFragment.kt | 2 +- .../auxio/home/list/PlaylistListFragment.kt | 2 +- .../auxio/home/list/SongListFragment.kt | 6 +- .../oxycblt/auxio/image/StyledImageView.kt | 2 +- .../auxio/image/extractor/CoverExtractor.kt | 1 - .../org/oxycblt/auxio/list/ListFragment.kt | 10 +- .../main/java/org/oxycblt/auxio/list/Sort.kt | 15 +- .../auxio/list/recycler/ViewHolders.kt | 23 +- .../java/org/oxycblt/auxio/music/Music.kt | 107 +-------- .../auxio/music/cache/CacheDatabase.kt | 2 +- .../auxio/music/device/DeviceMusicImpl.kt | 36 ++- .../oxycblt/auxio/music/device/RawMusic.kt | 2 + .../auxio/music/fs/MediaStoreExtractor.kt | 2 +- .../auxio/music/{metadata => info}/Date.kt | 2 +- .../auxio/music/{metadata => info}/Disc.kt | 6 +- .../java/org/oxycblt/auxio/music/info/Name.kt | 217 ++++++++++++++++++ .../music/{metadata => info}/ReleaseType.kt | 2 +- .../{AudioInfo.kt => AudioProperties.kt} | 25 +- .../auxio/music/metadata/MetadataModule.kt | 2 +- .../oxycblt/auxio/music/metadata/TagWorker.kt | 1 + .../oxycblt/auxio/music/user/PlaylistImpl.kt | 17 +- .../oxycblt/auxio/music/user/UserLibrary.kt | 2 +- .../auxio/navigation/NavigationViewModel.kt | 4 +- .../org/oxycblt/auxio/picker/ChoiceAdapter.kt | 4 +- .../auxio/playback/PlaybackBarFragment.kt | 2 +- .../auxio/playback/PlaybackPanelFragment.kt | 6 +- .../auxio/playback/queue/QueueAdapter.kt | 2 +- .../playback/system/MediaSessionComponent.kt | 13 +- .../auxio/playback/system/PlaybackService.kt | 2 +- .../org/oxycblt/auxio/search/SearchEngine.kt | 23 +- .../oxycblt/auxio/widgets/WidgetProvider.kt | 5 +- app/src/main/res/values/strings.xml | 2 +- .../java/org/oxycblt/auxio/music/FakeMusic.kt | 6 +- .../auxio/music/device/DeviceMusicImplTest.kt | 2 +- .../music/{metadata => info}/DateTest.kt | 2 +- .../music/{metadata => info}/DiscTest.kt | 2 +- .../{metadata => info}/ReleaseTypeTest.kt | 2 +- 51 files changed, 384 insertions(+), 255 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{metadata => info}/Date.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{metadata => info}/Disc.kt (80%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/info/Name.kt rename app/src/main/java/org/oxycblt/auxio/music/{metadata => info}/ReleaseType.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/metadata/{AudioInfo.kt => AudioProperties.kt} (84%) rename app/src/test/java/org/oxycblt/auxio/music/{metadata => info}/DateTest.kt (98%) rename app/src/test/java/org/oxycblt/auxio/music/{metadata => info}/DiscTest.kt (97%) rename app/src/test/java/org/oxycblt/auxio/music/{metadata => info}/ReleaseTypeTest.kt (98%) 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 0e81847ad..d6992458d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -194,7 +194,7 @@ class AlbumDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = album.resolveName(requireContext()) + requireBinding().detailToolbar.title = album.name.resolve(requireContext()) albumHeaderAdapter.setParent(album) } 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 7e7b70816..23e1e3456 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -204,7 +204,7 @@ class ArtistDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = artist.resolveName(requireContext()) + requireBinding().detailToolbar.title = artist.name.resolve(requireContext()) artistHeaderAdapter.setParent(artist) } 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 8b2baac6c..f176d85a0 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -36,9 +36,9 @@ 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.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.* @@ -53,7 +53,7 @@ class DetailViewModel @Inject constructor( private val musicRepository: MusicRepository, - private val audioInfoFactory: AudioInfo.Factory, + private val audioPropertiesFactory: AudioProperties.Factory, private val musicSettings: MusicSettings, private val playbackSettings: PlaybackSettings ) : ViewModel(), MusicRepository.UpdateListener { @@ -66,9 +66,9 @@ constructor( 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 --- @@ -225,7 +225,7 @@ constructor( /** * 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]. + * [songAudioProperties] will be updated to align with the new [Song]. * * @param uid The UID of the [Song] to load. Must be valid. */ @@ -305,12 +305,12 @@ constructor( 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 = audioInfoFactory.extract(song) + val info = audioPropertiesFactory.extract(song) yield() - _songAudioInfo.value = info + _songAudioProperties.value = info } } 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 3335e6068..302c3cfbf 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -196,7 +196,7 @@ class GenreDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = genre.resolveName(requireContext()) + requireBinding().detailToolbar.title = genre.name.resolve(requireContext()) genreHeaderAdapter.setParent(genre) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 635574c13..c8d9083d4 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -187,7 +187,7 @@ class PlaylistDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = playlist.resolveName(requireContext()) + requireBinding().detailToolbar.title = playlist.name.resolve(requireContext()) playlistHeaderAdapter.setParent(playlist) } 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 8de46fb72..615f256e4 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 @@ -67,10 +68,10 @@ class SongDetailDialog : ViewBindingDialogFragment() { binding.detailProperties.adapter = detailAdapter // DetailViewModel handles most initialization from the navigation argument. detailModel.setSongUid(args.songUid) - collectImmediately(detailModel.currentSong, detailModel.songAudioInfo, ::updateSong) + collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong) } - private fun updateSong(song: Song?, info: AudioInfo?) { + private fun updateSong(song: Song?, info: AudioProperties?) { if (song == null) { // 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 c7747ce2c..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 @@ -77,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 0e0e0a691..813615fc3 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 @@ -63,7 +63,7 @@ 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) if (artist.songs.isNotEmpty()) { // Information about the artist's genre(s) map to the sub-head text diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt index 99a816391..42e2b4f09 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/GenreDetailHeaderAdapter.kt @@ -62,7 +62,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) : fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) { binding.detailCover.bind(genre) binding.detailType.text = binding.context.getString(R.string.lbl_genre) - binding.detailName.text = genre.resolveName(binding.context) + binding.detailName.text = genre.name.resolve(binding.context) // Nothing about a genre is applicable to the sub-head text. binding.detailSubhead.isVisible = false // The song and artist count of the genre maps to the info text. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt index db6464f93..e0825af8b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt @@ -62,7 +62,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) : fun bind(playlist: Playlist, listener: DetailHeaderAdapter.Listener) { binding.detailCover.bind(playlist) binding.detailType.text = binding.context.getString(R.string.lbl_playlist) - binding.detailName.text = playlist.resolveName(binding.context) + binding.detailName.text = playlist.name.resolve(binding.context) // Nothing about a playlist is applicable to the sub-head text. binding.detailSubhead.isVisible = false // The song count of the playlist maps to the info text. 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..b3d01e970 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 @@ -171,7 +171,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 +204,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..f27a11100 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 @@ -106,7 +106,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 +139,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 +161,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 +191,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/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index 2266f56a1..765a39154 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 @@ -94,10 +94,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 8b3a5c369..33de26ea3 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 @@ -93,7 +93,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) 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 b6ba1d9cf..eca18c2a2 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 @@ -92,7 +92,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) 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 index c8df535f2..5afeb7dc6 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -85,7 +85,7 @@ class PlaylistListFragment : // 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.sortName?.thumbString + is Sort.Mode.ByName -> playlist.name.thumb // Duration -> Use formatted duration is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false) 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 d24f6abab..9b34f8a70 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 @@ -100,13 +100,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/image/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt index 8523ae18b..3f9f58671 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt @@ -147,7 +147,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr CoilUtils.dispose(this) imageLoader.enqueue(request) // Update the content description to the specified resource. - contentDescription = context.getString(descRes, music.resolveName(context)) + contentDescription = context.getString(descRes, music.name.resolve(context)) } /** 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 83bd50fd5..edb62ddbd 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 @@ -46,7 +46,6 @@ constructor( private val imageSettings: ImageSettings, private val mediaSourceFactory: MediaSource.Factory ) { - suspend fun extract(album: Album): InputStream? = try { when (imageSettings.coverMode) { 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 d9c48151a..8181fbee0 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -81,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) { @@ -120,7 +120,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) { @@ -157,7 +157,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) { @@ -191,7 +191,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) { @@ -225,7 +225,7 @@ abstract class ListFragment : * @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.rawName}") + logD("Launching new playlist menu: ${playlist.name}") openMusicMenuImpl(anchor, menuRes) { when (it.itemId) { 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 3be7b0051..c0cce8a5c 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. @@ -566,16 +566,7 @@ data class Sort(val mode: Mode, val direction: Direction) { * @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 - } - } + override fun compare(a: T, b: T) = a.name.compareTo(b.name) companion object { /** A re-usable instance configured for [Song]s. */ 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 7526ad0c9..0bf991037 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 @@ -51,7 +51,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 +80,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 +101,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 +130,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,7 +153,7 @@ 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( @@ -193,7 +192,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 +215,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, @@ -249,7 +248,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding val DIFF_CALLBACK = object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean = - oldItem.rawName == newItem.rawName && + oldItem.name == newItem.name && oldItem.artists.size == newItem.artists.size && oldItem.songs.size == newItem.songs.size } @@ -272,7 +271,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind fun bind(playlist: Playlist, listener: SelectableListListener) { listener.bind(playlist, this, menuButton = binding.parentMenu) binding.parentImage.bind(playlist) - binding.parentName.text = playlist.resolveName(binding.context) + binding.parentName.text = playlist.name.resolve(binding.context) binding.parentInfo.text = binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size) } @@ -303,7 +302,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind val DIFF_CALLBACK = object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist): Boolean = - oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size + oldItem.name == newItem.name && oldItem.songs.size == newItem.songs.size } } } 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 072a55f3a..9c3d37a7e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -22,8 +22,6 @@ import android.content.Context import android.net.Uri import android.os.Parcelable import androidx.room.TypeConverter -import java.text.CollationKey -import java.text.Collator import java.util.UUID import kotlin.math.max import kotlinx.parcelize.IgnoredOnParcel @@ -31,9 +29,10 @@ import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.Path -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.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 +50,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. @@ -342,61 +314,6 @@ interface Playlist : MusicParent { val durationMs: Long } -/** - * 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". - * - * @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 - } - } - - sortName = sortName.replace(CONSECUTIVE_DIGITS_REGEX) { it.value.padStart(6, '0') } - } - - collationKey = COLLATOR.getCollationKey(sortName) - - // Keep track of a string to use in the thumb view. - // Simply show '#' for everything before 'A' - // TODO: This needs to be moved elsewhere. - thumbString = - collationKey?.run { - val thumbChar = sourceString.firstOrNull() - if (thumbChar?.isLetter() == true) thumbChar.uppercase() else "#" - } - } - - override fun toString(): String = collationKey.sourceString - - override fun compareTo(other: SortName) = collationKey.compareTo(other.collationKey) - - override fun equals(other: Any?) = other is SortName && collationKey == other.collationKey - - override fun hashCode(): Int = collationKey.hashCode() - - private companion object { - val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY } - val LEADING_PUNCTUATION_REGEX = Regex("[\\p{Punct}+]") - val CONSECUTIVE_DIGITS_REGEX = Regex("\\d+") - } -} - /** * Run [Music.resolveName] on each instance in the given list and concatenate them into a [String] * in a localized manner. @@ -405,20 +322,20 @@ class SortName(name: String, musicSettings: MusicSettings) : Comparable 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/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 86e9e4de4..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 @@ -28,7 +28,7 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverter import androidx.room.TypeConverters import org.oxycblt.auxio.music.device.RawSong -import org.oxycblt.auxio.music.metadata.Date +import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 76f78c4e2..b0ddb0ca7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.music.device -import android.content.Context import androidx.annotation.VisibleForTesting import java.security.MessageDigest import java.util.* @@ -29,9 +28,8 @@ 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.metadata.Date -import org.oxycblt.auxio.music.metadata.Disc -import org.oxycblt.auxio.music.metadata.ReleaseType +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.util.nonZeroOrNull @@ -63,10 +61,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) } @@ -239,10 +238,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) @@ -332,12 +328,11 @@ 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) } ?: createHashedUid(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 @@ -417,10 +412,9 @@ class GenreImpl( override val songs: List ) : Genre { override val uid = createHashedUid(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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index 571e90ff5..6cb66f0ae 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -21,6 +21,8 @@ package org.oxycblt.auxio.music.device import java.util.UUID import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.fs.Directory +import org.oxycblt.auxio.music.info.Date +import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.metadata.* /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 9e3d1f2d5..4603af11e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.yield import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.cache.Cache import org.oxycblt.auxio.music.device.RawSong -import org.oxycblt.auxio.music.metadata.Date +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.util.getSystemServiceCompat 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..685a9e0c4 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 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 80% 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..df1cafea4 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 @@ -26,8 +26,6 @@ import org.oxycblt.auxio.list.Item * @param number The disc number. * @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 +data class Disc(val number: Int, val name: String?) : Item, Comparable { 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..e985a115c --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -0,0 +1,217 @@ +/* + * 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 view + */ + 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 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 to obtain user-preferred sorting + * configurations + */ + 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 = 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 > 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 + } + } + + // 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 = 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 99% 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..bc676228b 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 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 84% 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 2dc906563..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 @@ -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]. */ + /** 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.Factory]. + * A framework-backed implementation of [AudioProperties.Factory]. * * @param context [Context] required to read audio files. */ -class AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val context: Context) : - AudioInfo.Factory { +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 AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val c // 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 AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val c 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 650acbe60..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 @@ -28,5 +28,5 @@ import dagger.hilt.components.SingletonComponent interface MetadataModule { @Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor @Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory - @Binds fun audioInfoProvider(factory: AudioInfoFactoryImpl): AudioInfo.Factory + @Binds fun audioPropertiesFactory(factory: AudioPropertiesFactoryImpl): AudioProperties.Factory } 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 2a096c789..53bacbf08 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 @@ -27,6 +27,7 @@ import java.util.concurrent.Future import javax.inject.Inject 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 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 index 814f12b0c..fa02f2b99 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -18,20 +18,17 @@ package org.oxycblt.auxio.music.user -import android.content.Context import java.util.* 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 rawName: String, - override val sortName: SortName, + override val name: Name, override val songs: List ) : Playlist { - override fun resolveName(context: Context) = rawName - override val rawSortName = null override val durationMs = songs.sumOf { it.durationMs } override val albums = songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key } @@ -41,7 +38,7 @@ private constructor( * * @param songs The new [Song]s to use. */ - fun edit(songs: List) = PlaylistImpl(uid, rawName, sortName, songs) + fun edit(songs: List) = PlaylistImpl(uid, name, songs) /** * Clone the data in this instance to a new [PlaylistImpl] with the given [edits]. @@ -58,11 +55,10 @@ private constructor( * @param songs The songs to initially populate the playlist with. * @param musicSettings [MusicSettings] required for name configuration. */ - fun new(name: String, songs: List, musicSettings: MusicSettings) = + fun from(name: String, songs: List, musicSettings: MusicSettings) = PlaylistImpl( Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()), - name, - SortName(name, musicSettings), + Name.Known.from(name, null, musicSettings), songs) /** @@ -79,8 +75,7 @@ private constructor( ) = PlaylistImpl( rawPlaylist.playlistInfo.playlistUid, - rawPlaylist.playlistInfo.name, - SortName(rawPlaylist.playlistInfo.name, musicSettings), + 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/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index f34ae6648..34717d77e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -106,7 +106,7 @@ private class UserLibraryImpl( @Synchronized override fun createPlaylist(name: String, songs: List) { - val playlistImpl = PlaylistImpl.new(name, songs, musicSettings) + val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) playlistMap[playlistImpl.uid] = playlistImpl } diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt index 27125123f..6e2f43f83 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/NavigationViewModel.kt @@ -85,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) } @@ -118,7 +118,7 @@ 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) } } diff --git a/app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt index 31cd2c085..723777018 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt @@ -63,7 +63,7 @@ private constructor(private val binding: ItemPickerChoiceBinding) : is Genre -> binding.pickerImage.bind(music) is Playlist -> binding.pickerImage.bind(music) } - binding.pickerName.text = music.resolveName(binding.context) + binding.pickerName.text = music.name.resolve(binding.context) } companion object { @@ -81,7 +81,7 @@ private constructor(private val binding: ItemPickerChoiceBinding) : fun diffCallback() = object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: T, newItem: T) = - oldItem.rawName == newItem.rawName + oldItem.name == newItem.name } } } 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 b33a973ff..f49d6df44 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -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/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index f76a5da9a..636e2e2ca 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -188,9 +188,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() } @@ -198,7 +198,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/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index 2230fe7a2..df4ac8c1d 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 @@ -150,7 +150,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong fun bind(song: Song, listener: EditableListListener) { listener.bind(song, this, bodyView, 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. 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..1227b637c 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 @@ -289,12 +289,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) @@ -305,14 +305,17 @@ constructor( .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist) .putText( + // TODO: Remove in favor of METADATA_KEY_DISPLAY_DESCRIPTION METADATA_KEY_PARENT, - parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)) + parent?.run { name.resolve(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 +356,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. 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 e6ae6a8d0..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 @@ -227,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 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..a04fb9e70 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -27,6 +27,7 @@ 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.info.Name /** * Implements the fuzzy-ish searching algorithm used in the search view. @@ -63,7 +64,11 @@ 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 -> + // FIXME: Match case-insensitively + song.path.name.contains(q) + }, albums = items.albums?.searchListImpl(query), artists = items.artists?.searchListImpl(query), genres = items.genres?.searchListImpl(query)) @@ -84,17 +89,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 +112,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 } 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/values/strings.xml b/app/src/main/res/values/strings.xml index c0de9a5c8..811c2c573 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 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 23a55bc1f..e835294e4 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt @@ -22,9 +22,9 @@ import android.content.Context import android.net.Uri import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.Path -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.info.Date +import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.music.info.ReleaseType open class FakeSong : Song { override val rawName: String? diff --git a/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt b/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt index f548a4719..77076670d 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt @@ -24,7 +24,7 @@ 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 +import org.oxycblt.auxio.music.info.Date class DeviceMusicImplTest { @Test 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 From b72f33a9898ffcc0e2b297acda6dd5702d811364 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 12:21:58 -0600 Subject: [PATCH 31/88] search: match file names case-insensitively Not doing this lead to some inconsistent search results at points. Resolves #437. --- CHANGELOG.md | 5 +++++ app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b3d8295..6b6b8d61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,14 @@ ## dev +#### 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 ## 3.0.5 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 a04fb9e70..675c4b0e4 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -66,8 +66,7 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte SearchEngine.Items( songs = items.songs?.searchListImpl(query) { q, song -> - // FIXME: Match case-insensitively - song.path.name.contains(q) + song.path.name.contains(q, ignoreCase = true) }, albums = items.albums?.searchListImpl(query), artists = items.artists?.searchListImpl(query), From 4e5a3f7fe1e2dddbb211b38533f51ec5aa635408 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 12:25:20 -0600 Subject: [PATCH 32/88] tests: fix failure Fix some accidental regressions and unported mocks. --- .../java/org/oxycblt/auxio/music/info/Disc.kt | 4 +- .../java/org/oxycblt/auxio/music/info/Name.kt | 37 ++++++++-------- .../java/org/oxycblt/auxio/music/FakeMusic.kt | 42 +++---------------- 3 files changed, 28 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt index df1cafea4..759d52b49 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt @@ -26,6 +26,8 @@ import org.oxycblt.auxio.list.Item * @param number The disc number. * @param name The name of the disc group, if any. Null if not present. */ -data class Disc(val number: Int, val name: String?) : Item, Comparable { +class Disc(val number: Int, val name: String?) : Item, Comparable { + 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 index e985a115c..8869e1227 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -111,10 +111,11 @@ sealed interface Name : Comparable { 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 to obtain user-preferred sorting - * configurations + * configurations */ fun from(raw: String, sort: String?, musicSettings: MusicSettings): Known = if (musicSettings.intelligentSorting) { @@ -148,6 +149,7 @@ private val PUNCT_REGEX = 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?) : @@ -165,6 +167,7 @@ private data class SimpleKnownName(override val raw: String, override val sort: /** * [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?) : @@ -192,23 +195,23 @@ private data class IntelligentKnownName(override val raw: String, override val s // 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) + // 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 { 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 e835294e4..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,20 +18,16 @@ package org.oxycblt.auxio.music -import android.content.Context import android.net.Uri 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() @@ -59,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() @@ -88,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() @@ -113,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() @@ -136,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() - } } From aa24ea00ea59e8516bfc362202e8d55a9ae89c99 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 12:36:58 -0600 Subject: [PATCH 33/88] all: use sealed interface when possible Use sealed interface instead of sealed class when no class features are actually used by the code. --- .../main/java/org/oxycblt/auxio/list/Sort.kt | 369 +++++++++--------- .../auxio/list/adapter/FlexibleListAdapter.kt | 12 +- .../oxycblt/auxio/music/info/ReleaseType.kt | 20 +- .../auxio/navigation/MainNavigationAction.kt | 8 +- .../auxio/playback/state/InternalPlayer.kt | 8 +- 5 files changed, 209 insertions(+), 208 deletions(-) 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 c0cce8a5c..8b5120a7a 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt @@ -155,11 +155,11 @@ 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]. @@ -168,7 +168,7 @@ data class Sort(val mode: Mode, val direction: Direction) { * @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? = null + fun getSongComparator(direction: Direction): Comparator? = null /** * Get a [Comparator] that sorts [Album]s according to this [Mode]. @@ -177,7 +177,8 @@ data class Sort(val mode: Mode, val direction: Direction) { * @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? = null + fun getAlbumComparator(direction: Direction): Comparator? = null + /** * Return a [Comparator] that sorts [Artist]s according to this [Mode]. * @@ -185,7 +186,7 @@ data class Sort(val mode: Mode, val direction: Direction) { * @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? = null + fun getArtistComparator(direction: Direction): Comparator? = null /** * Return a [Comparator] that sorts [Genre]s according to this [Mode]. @@ -194,7 +195,7 @@ data class Sort(val mode: Mode, val direction: Direction) { * @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? = null + fun getGenreComparator(direction: Direction): Comparator? = null /** * Return a [Comparator] that sorts [Playlist]s according to this [Mode]. @@ -203,14 +204,14 @@ data class Sort(val mode: Mode, val direction: Direction) { * @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 getPlaylistComparator(direction: Direction): Comparator? = null + fun getPlaylistComparator(direction: Direction): Comparator? = null /** * Sort by the item's natural order. * - * @see Music.sortName + * @see Music.name */ - object ByNone : Mode() { + object ByNone : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_NONE @@ -223,7 +224,7 @@ data class Sort(val mode: Mode, val direction: Direction) { * * @see Music.sortName */ - object ByName : Mode() { + object ByName : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_NAME @@ -249,9 +250,9 @@ data class Sort(val mode: Mode, val direction: Direction) { /** * Sort by the [Album] of an item. Only available for [Song]s. * - * @see Album.sortName + * @see Album.name */ - object ByAlbum : Mode() { + object ByAlbum : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_ALBUM @@ -269,9 +270,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 @@ -300,7 +301,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 @@ -322,7 +323,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 @@ -357,7 +358,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 @@ -388,7 +389,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 @@ -407,7 +408,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 @@ -427,7 +428,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 @@ -444,169 +445,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) = 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() - } - } - companion object { /** * Convert a [Mode] integer representation into an instance. @@ -678,3 +516,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 [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 [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() + } +} \ No newline at end of file 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..c76ffaae6 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,14 @@ 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. */ - data class Remove(val at: Int) : UpdateInstructions() + data class Remove(val at: Int) : UpdateInstructions } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt index bc676228b..20ac60034 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt @@ -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/navigation/MainNavigationAction.kt b/app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt index 36eec45f7..59c778320 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/MainNavigationAction.kt @@ -27,12 +27,12 @@ import androidx.navigation.NavDirections * * @author Alexander Capehart (OxygenCobalt) */ -sealed class MainNavigationAction { +sealed interface MainNavigationAction { /** Expand the playback panel. */ - object OpenPlaybackPanel : MainNavigationAction() + object OpenPlaybackPanel : MainNavigationAction /** Collapse the playback bottom sheet. */ - object ClosePlaybackPanel : MainNavigationAction() + object ClosePlaybackPanel : MainNavigationAction /** * Navigate to the given [NavDirections]. @@ -40,5 +40,5 @@ sealed class MainNavigationAction { * @param directions The [NavDirections] to navigate to. Assumed to be part of the main * navigation graph. */ - data class Directions(val directions: NavDirections) : MainNavigationAction() + data class Directions(val directions: NavDirections) : MainNavigationAction } 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 } /** From c98ca8712f627c869272fcbfa566da99173b6fe5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 12:56:25 -0600 Subject: [PATCH 34/88] music: re-add fallible async execution Re-add the tryAsync wrapper function to the loading process to properly handle music loading errors. Turns out there is basically no other way to properly do this except for the insane exception -> Result -> exception hack. --- .../oxycblt/auxio/music/MusicRepository.kt | 42 ++++++++++++++----- .../auxio/music/fs/MediaStoreExtractor.kt | 1 - .../auxio/music/metadata/TagExtractor.kt | 2 - .../java/org/oxycblt/auxio/util/LangUtil.kt | 18 ++++---- 4 files changed, 40 insertions(+), 23 deletions(-) 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 ac0bf498b..2dfc66d80 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -25,15 +25,19 @@ import java.util.* import javax.inject.Inject import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel +import org.oxycblt.auxio.R 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.UserLibrary +import org.oxycblt.auxio.util.fallible import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext /** * Primary manager of music information and loading. @@ -272,14 +276,14 @@ constructor( // Do the initial query of the cache and media databases in parallel. logD("Starting queries") - val mediaStoreQueryJob = worker.scope.async { mediaStoreExtractor.query() } + val mediaStoreQueryJob = worker.scope.tryAsync { mediaStoreExtractor.query() } val cache = if (withCache) { cacheRepository.readCache() } else { null } - val query = mediaStoreQueryJob.await() + 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 @@ -288,11 +292,15 @@ constructor( val completeSongs = Channel(Channel.UNLIMITED) val incompleteSongs = Channel(Channel.UNLIMITED) val mediaStoreJob = - worker.scope.async { - mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) + worker.scope.tryAsync { + mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) }.also { + incompleteSongs.close() } val metadataJob = - worker.scope.async { tagExtractor.consume(incompleteSongs, completeSongs) } + worker.scope.tryAsync { + tagExtractor.consume(incompleteSongs, completeSongs) + completeSongs.close() + } // Await completed raw songs as they are processed. val rawSongs = LinkedList() @@ -301,8 +309,8 @@ constructor( emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) } // These should be no-ops - mediaStoreJob.await() - metadataJob.await() + mediaStoreJob.await().getOrThrow() + metadataJob.await().getOrThrow() if (rawSongs.isEmpty()) { logE("Music library was empty") @@ -315,21 +323,33 @@ constructor( emitLoading(IndexingProgress.Indeterminate) val deviceLibraryChannel = Channel() val deviceLibraryJob = - worker.scope.async(Dispatchers.Main) { + worker.scope.tryAsync(Dispatchers.Main) { deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) } } - val userLibraryJob = worker.scope.async { userLibraryFactory.read(deviceLibraryChannel) } + val userLibraryJob = worker.scope.tryAsync { userLibraryFactory.read(deviceLibraryChannel) } if (cache == null || cache.invalidated) { cacheRepository.writeCache(rawSongs) } - val deviceLibrary = deviceLibraryJob.await() - val userLibrary = userLibraryJob.await() + 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) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 4603af11e..0df80983b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -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() } /** 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 7e31400ff..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 @@ -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/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt index 047c24c21..bb68cecee 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -35,16 +35,16 @@ fun unlikelyToBeNull(value: T?) = } /** - * 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]. + * Maps a try expression to a [Result]. + * @param block The code to execute + * @return A [Result] representing the outcome of [block]'s execution. */ -inline fun requireIs(data: Any?): T { - check(data is T) { "Unexpected datatype: ${data?.let { it::class.simpleName }}" } - return data -} +inline fun fallible(block: () -> R) = + try { + Result.success(block()) + } catch (e: Exception) { + Result.failure(e) + } /** * Aliases a check to ensure that the given number is non-zero. From a5176d620916101b7378a1aacf3b5c7288012901 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 13:04:07 -0600 Subject: [PATCH 35/88] all: do not do work on static initialization Try to lazily initialize certain static variables that do work (like Regex) to speed up initialization time. --- app/src/main/java/org/oxycblt/auxio/list/Sort.kt | 14 +++++++------- .../org/oxycblt/auxio/music/MusicRepository.kt | 12 +++++++----- .../main/java/org/oxycblt/auxio/music/info/Date.kt | 3 ++- .../main/java/org/oxycblt/auxio/music/info/Name.kt | 11 +++++------ .../org/oxycblt/auxio/music/metadata/TagUtil.kt | 2 +- .../org/oxycblt/auxio/music/user/PlaylistImpl.kt | 5 +++++ .../replaygain/ReplayGainAudioProcessor.kt | 2 +- .../java/org/oxycblt/auxio/search/SearchEngine.kt | 3 ++- .../main/java/org/oxycblt/auxio/util/LangUtil.kt | 12 ------------ 9 files changed, 30 insertions(+), 34 deletions(-) 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 8b5120a7a..f8e73adc7 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt @@ -568,8 +568,8 @@ private inline fun compareByDynamic( } /** - * Utility function to create a [Comparator] that sorts in ascending order based on the - * given [Comparator], with a selector based on the item itself. + * 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. @@ -581,8 +581,8 @@ private fun compareBy(comparator: Comparator): Comparator = /** * 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. + * @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 @@ -656,8 +656,8 @@ private class BasicComparator private constructor() : Comparator { } /** - * A [Comparator] that compares two possibly null values. Values will be considered lesser - * if they are null, and greater if they are non-null. + * 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?) = @@ -678,4 +678,4 @@ private class NullableComparator> private constructor() : Comp /** A re-usable instance configured for [Date.Range]s. */ val DATE_RANGE = NullableComparator() } -} \ No newline at end of file +} 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 2dfc66d80..e91bdcbbe 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -23,6 +23,8 @@ import android.content.pm.PackageManager import androidx.core.content.ContextCompat import java.util.* import javax.inject.Inject +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import org.oxycblt.auxio.R @@ -32,12 +34,9 @@ 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.UserLibrary -import org.oxycblt.auxio.util.fallible import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext /** * Primary manager of music information and loading. @@ -293,7 +292,7 @@ constructor( val incompleteSongs = Channel(Channel.UNLIMITED) val mediaStoreJob = worker.scope.tryAsync { - mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) }.also { + mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) incompleteSongs.close() } val metadataJob = @@ -326,7 +325,10 @@ constructor( worker.scope.tryAsync(Dispatchers.Main) { deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) } } - val userLibraryJob = worker.scope.tryAsync { userLibraryFactory.read(deviceLibraryChannel) } + val userLibraryJob = + worker.scope.tryAsync { + userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() } + } if (cache == null || cache.invalidated) { cacheRepository.writeCache(rawSongs) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt index 685a9e0c4..46e4130f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt @@ -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/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index 8869e1227..3e61cdf6c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -35,7 +35,7 @@ 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 view + * TODO: Move this to the home package */ val thumb: String @@ -87,7 +87,7 @@ sealed interface Name : Comparable { final override val thumb: String get() = - // TODO: Remove these checks once you have real unit testing + // TODO: Remove these safety checks once you have real unit testing sortTokens .firstOrNull() ?.run { collationKey.sourceString.firstOrNull() } @@ -114,8 +114,7 @@ sealed interface Name : Comparable { * * @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 to obtain user-preferred sorting - * configurations + * @param musicSettings [MusicSettings] required for name configuration. */ fun from(raw: String, sort: String?, musicSettings: MusicSettings): Known = if (musicSettings.intelligentSorting) { @@ -145,7 +144,7 @@ sealed interface Name : Comparable { } private val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY } -private val PUNCT_REGEX = Regex("[\\p{Punct}+]") +private val PUNCT_REGEX by lazy { Regex("[\\p{Punct}+]") } /** * Plain [Name.Known] implementation that is internationalization-safe. @@ -215,6 +214,6 @@ private data class IntelligentKnownName(override val raw: String, override val s } companion object { - private val TOKEN_REGEX = Regex("(\\d+)|(\\D+)") + private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") } } } 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..a8a1ba634 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 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 index fa02f2b99..cbab2a634 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -47,6 +47,11 @@ private constructor( */ inline fun edit(edits: MutableList.() -> Unit) = edit(songs.toMutableList().apply(edits)) + override fun equals(other: Any?) = + other is PlaylistImpl && uid == other.uid && songs == other.songs + + override fun hashCode() = 31 * uid.hashCode() + songs.hashCode() + companion object { /** * Create a new instance with a novel UID. 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 fafe5266c..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 @@ -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/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index 675c4b0e4..5d8e54318 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -125,7 +125,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/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt index bb68cecee..1aab1edd1 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -34,18 +34,6 @@ fun unlikelyToBeNull(value: T?) = value!! } -/** - * Maps a try expression to a [Result]. - * @param block The code to execute - * @return A [Result] representing the outcome of [block]'s execution. - */ -inline fun fallible(block: () -> R) = - try { - Result.success(block()) - } catch (e: Exception) { - Result.failure(e) - } - /** * Aliases a check to ensure that the given number is non-zero. * From cb69400905bac544b073b1bbde84807834dac434 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 13:15:42 -0600 Subject: [PATCH 36/88] music: add playlist creation stub Add a stub for creating a new playlist. UX details are not worked out yet, so the functionality is still extremely bare. --- .../oxycblt/auxio/music/MusicRepository.kt | 21 +++++++++++++++++-- .../org/oxycblt/auxio/music/MusicViewModel.kt | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) 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 e91bdcbbe..0542e0b59 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -33,6 +33,7 @@ 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 @@ -110,6 +111,14 @@ interface MusicRepository { */ 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. + */ + fun createPlaylist(name: String, songs: List) + /** * Request that a music loading operation is started by the current [IndexingWorker]. Does * nothing if one is not available. @@ -183,7 +192,7 @@ constructor( private var indexingWorker: MusicRepository.IndexingWorker? = null override var deviceLibrary: DeviceLibrary? = null - override var userLibrary: UserLibrary? = null + override var userLibrary: MutableUserLibrary? = null private var previousCompletedState: IndexingState.Completed? = null private var currentIndexingState: IndexingState? = null override val indexingState: IndexingState? @@ -237,6 +246,14 @@ constructor( (deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) } ?: userLibrary?.findPlaylist(uid)) + override fun createPlaylist(name: String, songs: List) { + userLibrary?.createPlaylist(name, songs) + for (listener in updateListeners) { + listener.onMusicChanges( + MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) + } + } + override fun requestIndex(withCache: Boolean) { indexingWorker?.requestIndex(withCache) } @@ -374,7 +391,7 @@ constructor( } @Synchronized - private fun emitData(deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) { + private fun emitData(deviceLibrary: DeviceLibrary, userLibrary: MutableUserLibrary) { val deviceLibraryChanged = this.deviceLibrary != deviceLibrary val userLibraryChanged = this.userLibrary != userLibrary if (!deviceLibraryChanged && !userLibraryChanged) return 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 9397661ba..13a7b0fc3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -84,7 +84,7 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos * @param name The name of the new playlist. If null, the user will be prompted for a name. */ fun createPlaylist(name: String? = null) { - // TODO: Implement + musicRepository.createPlaylist(name ?: "New playlist", listOf()) } /** From a8691cf693a6fdc048361539c5976383c092c5f4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 15:06:37 -0600 Subject: [PATCH 37/88] build: enable r8 full mode Enable R8 full mode for the app. Should shave off ~150kb. --- app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt | 3 ++- .../test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt | 4 ++++ gradle.properties | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) 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 0542e0b59..b04f035fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -247,7 +247,8 @@ constructor( ?: userLibrary?.findPlaylist(uid)) override fun createPlaylist(name: String, songs: List) { - userLibrary?.createPlaylist(name, songs) + val userLibrary = userLibrary ?: return + userLibrary.createPlaylist(name, songs) for (listener in updateListeners) { listener.onMusicChanges( MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt index b3ae9e553..2d113a3f1 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt @@ -58,6 +58,10 @@ open class FakeMusicRepository : MusicRepository { throw NotImplementedError() } + override fun createPlaylist(name: String, songs: List) { + throw NotImplementedError() + } + override fun requestIndex(withCache: Boolean) { throw NotImplementedError() } diff --git a/gradle.properties b/gradle.properties index f6e147bcb..855d32826 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,3 +19,4 @@ android.useAndroidX=true android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official +android.enableR8.fullMode=true \ No newline at end of file From 43036cfd59b3e7c989813463cd26f733cacc2361 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 15:10:35 -0600 Subject: [PATCH 38/88] playback: fix notif not responding to settings Fix an issue where changes in image settings would not cause the notification to respond. --- .../oxycblt/auxio/playback/system/MediaSessionComponent.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 1227b637c..8ae870527 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 @@ -55,9 +55,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 +77,7 @@ constructor( init { playbackManager.addListener(this) playbackSettings.registerListener(this) + imageSettings.registerListener(this) mediaSession.setCallback(this) } @@ -105,6 +107,7 @@ constructor( listener = null bitmapProvider.release() playbackSettings.unregisterListener(this) + imageSettings.unregisterListener(this) playbackManager.removeListener(this) mediaSession.apply { isActive = false From 715739f0055935cedca0e70369443a7073fd1eb7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 15:11:25 -0600 Subject: [PATCH 39/88] playback: remove parent mediasession value Is redundant now given that METADATA_KEY_DISPLAY_DESCRIPTION is used now. --- .../auxio/playback/system/MediaSessionComponent.kt | 10 ---------- 1 file changed, 10 deletions(-) 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 8ae870527..3358779cd 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 @@ -307,11 +307,6 @@ constructor( .putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist) .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist) - .putText( - // TODO: Remove in favor of METADATA_KEY_DISPLAY_DESCRIPTION - METADATA_KEY_PARENT, - parent?.run { name.resolve(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) @@ -444,11 +439,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 From e68cc4d62002c398e5f790a5a16a7f8ce1ab1590 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 11 May 2023 15:42:47 -0600 Subject: [PATCH 40/88] build: update deps Update a huge amount of dependencies. Forgot specifically what I did. --- CHANGELOG.md | 1 + app/build.gradle | 16 +++++++-------- .../java/org/oxycblt/auxio/MainActivity.kt | 1 - .../org/oxycblt/auxio/home/HomeFragment.kt | 5 ++--- .../playback/system/MediaSessionComponent.kt | 1 - build.gradle | 6 +++--- gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 62076 bytes gradle/wrapper/gradle-wrapper.properties | 7 ++++--- gradlew | 19 +++++++++++------- gradlew.bat | 1 + 10 files changed, 31 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b6b8d61d..5581a3903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - 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 ## 3.0.5 diff --git a/app/build.gradle b/app/build.gradle index 7f77e603d..a6a59f192 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -75,13 +75,14 @@ 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.0" + 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.10.1" implementation "androidx.activity:activity-ktx:1.7.1" @@ -111,7 +112,7 @@ dependencies { implementation "androidx.preference:preference-ktx:1.2.0" // Database - def room_version = '2.5.1' + 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" @@ -123,7 +124,7 @@ dependencies { 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 is actually available @@ -133,9 +134,8 @@ dependencies { 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" diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 410ea7904..70e34cb3e 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -48,7 +48,6 @@ 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: Use sealed interface where applicable * TODO: Fix UID naming * TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims) */ 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 291340b76..e042e59b0 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -150,8 +150,7 @@ class HomeFragment : // --- VIEWMODEL SETUP --- collect(homeModel.recreateTabs.flow, ::handleRecreate) collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) - collectImmediately( - homeModel.songsList, homeModel.isFastScrolling, homeModel.currentTabMode, ::updateFab) + collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab) collectImmediately(musicModel.indexingState, ::updateIndexerState) collect(navModel.exploreNavigationItem.flow, ::handleNavigation) collectImmediately(selectionModel.selected, ::updateSelection) @@ -428,7 +427,7 @@ class HomeFragment : } } - private fun updateFab(songs: List, isFastScrolling: Boolean, currentTabMode: MusicMode) { + private fun updateFab(songs: List, isFastScrolling: Boolean) { val binding = requireBinding() // If there are no songs, it's likely that the library has not been loaded, so // displaying the shuffle FAB makes no sense. We also don't want the fast scroll 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 3358779cd..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 diff --git a/build.gradle b/build.gradle index 2cbd23974..bd58d4d81 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' } repositories { @@ -14,7 +14,7 @@ buildscript { classpath 'com.android.tools.build:gradle:7.4.2' 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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f090a2944b7473328c07c9755baa3196..c1962a79e29d3e0ab67b14947c167a862655af9b 100644 GIT binary patch delta 39834 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 G$^QWPgI3l6 delta 38507 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 Date: Thu, 11 May 2023 16:18:14 -0600 Subject: [PATCH 41/88] all: unwind picker abstraction Unwind the picker abstraction into smaller dialog packages. While this increases repetition, it will make the playlist dialog implementations much less shoddy. --- .../auxio/list/recycler/ViewHolders.kt | 48 ++++++++ .../dialog/ArtistNavigationPickerDialog.kt | 104 +++++++++++++++++ .../NavigationPickerViewModel.kt | 50 +++++---- .../picker/ArtistNavigationPickerDialog.kt | 61 ---------- .../org/oxycblt/auxio/picker/ChoiceAdapter.kt | 87 -------------- .../org/oxycblt/auxio/picker/PickerChoices.kt | 31 ----- .../auxio/picker/PickerDialogFragment.kt | 85 -------------- .../dialog/ArtistPlaybackPickerDialog.kt | 106 ++++++++++++++++++ .../dialog/GenrePlaybackPickerDialog.kt | 104 +++++++++++++++++ .../PlaybackPickerViewModel.kt | 48 ++------ .../picker/ArtistPlaybackPickerDialog.kt | 63 ----------- .../picker/GenrePlaybackPickerDialog.kt | 64 ----------- .../main/res/layout/dialog_music_picker.xml | 2 +- app/src/main/res/navigation/nav_main.xml | 6 +- 14 files changed, 405 insertions(+), 454 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt rename app/src/main/java/org/oxycblt/auxio/navigation/{picker => dialog}/NavigationPickerViewModel.kt (69%) delete mode 100644 app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationPickerDialog.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/picker/PickerChoices.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/picker/PickerDialogFragment.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt rename app/src/main/java/org/oxycblt/auxio/playback/{picker => dialog}/PlaybackPickerViewModel.kt (51%) delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackPickerDialog.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackPickerDialog.kt 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 0bf991037..cc0603b4c 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 @@ -24,8 +24,10 @@ 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.ItemPickerChoiceBinding import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.list.BasicHeader +import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SimpleDiffCallback @@ -347,3 +349,49 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB } } } + +/** + * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [T] item, for use + * in choice dialogs. Use [from] to create an instance. + */ +class ChoiceViewHolder +private constructor(private val binding: ItemPickerChoiceBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + /** + * Bind new data to this instance. + * + * @param music The new [T] to bind. + * @param listener A [ClickableListListener] to bind interactions to. + */ + fun bind(music: T, listener: ClickableListListener) { + listener.bind(music, this) + // ImageGroup is not generic, so we must downcast to specific types for now. + when (music) { + is Song -> binding.pickerImage.bind(music) + is Album -> binding.pickerImage.bind(music) + is Artist -> binding.pickerImage.bind(music) + is Genre -> binding.pickerImage.bind(music) + is Playlist -> binding.pickerImage.bind(music) + } + binding.pickerName.text = music.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) = + ChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) + + /** Get a comparator that can be used with DiffUtil. */ + fun diffCallback() = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: T, newItem: T) = + oldItem.name == newItem.name + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt new file mode 100644 index 000000000..c292f00fe --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt @@ -0,0 +1,104 @@ +/* + * 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.navigation.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +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.DialogMusicPickerBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.list.recycler.ChoiceViewHolder +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 ArtistNavigationPickerDialog : + 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: ArtistNavigationPickerDialogArgs by navArgs() + private val choiceAdapter = ArtistChoiceAdapter(this) + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.lbl_artists).setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogMusicPickerBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.pickerChoiceRecycler.apply { + itemAnimator = null + adapter = choiceAdapter + } + + pickerModel.setArtistChoiceUid(args.artistUid) + collectImmediately(pickerModel.currentArtistChoices) { + if (it != null) { + choiceAdapter.update(it.choices, UpdateInstructions.Replace(0)) + } else { + findNavController().navigateUp() + } + } + } + + override fun onDestroyBinding(binding: DialogMusicPickerBinding) { + 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() + } + + private class ArtistChoiceAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter>(ChoiceViewHolder.diffCallback()) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ChoiceViewHolder = ChoiceViewHolder.from(parent) + + override fun onBindViewHolder(holder: ChoiceViewHolder, position: Int) { + holder.bind(getItem(position), listener) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationPickerViewModel.kt similarity index 69% rename from app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationPickerViewModel.kt index dc14b8ea0..193d9553a 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationPickerViewModel.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.navigation.picker +package org.oxycblt.auxio.navigation.dialog import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -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.picker.PickerChoices /** * A [ViewModel] that stores the current information required for [ArtistNavigationPickerDialog]. @@ -36,7 +35,7 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: ViewModel(), MusicRepository.UpdateListener { private val _currentArtistChoices = MutableStateFlow(null) /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ - val currentArtistChoices: StateFlow?> + val currentArtistChoices: StateFlow get() = _currentArtistChoices init { @@ -49,13 +48,13 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: // Need to sanitize different items depending on the current set of choices. _currentArtistChoices.value = when (val choices = _currentArtistChoices.value) { - is ArtistNavigationChoices.FromSong -> + is SongArtistNavigationChoices -> deviceLibrary.findSong(choices.song.uid)?.let { - ArtistNavigationChoices.FromSong(it) + SongArtistNavigationChoices(it) } - is ArtistNavigationChoices.FromAlbum -> + is AlbumArtistNavigationChoices -> deviceLibrary.findAlbum(choices.album.uid)?.let { - ArtistNavigationChoices.FromAlbum(it) + AlbumArtistNavigationChoices(it) } else -> null } @@ -75,19 +74,32 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: // Support Songs and Albums, which have parent artists. _currentArtistChoices.value = when (val music = musicRepository.find(uid)) { - is Song -> ArtistNavigationChoices.FromSong(music) - is Album -> ArtistNavigationChoices.FromAlbum(music) + is Song -> SongArtistNavigationChoices(music) + is Album -> AlbumArtistNavigationChoices(music) else -> null } } - - private sealed interface ArtistNavigationChoices : PickerChoices { - data class FromSong(val song: Song) : ArtistNavigationChoices { - override val choices = song.artists - } - - data class FromAlbum(val album: Album) : ArtistNavigationChoices { - override val choices = album.artists - } - } +} + +/** + * 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/navigation/picker/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationPickerDialog.kt deleted file mode 100644 index 24d49a8cf..000000000 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationPickerDialog.kt +++ /dev/null @@ -1,61 +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.navigation.picker - -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.navigation.NavigationViewModel -import org.oxycblt.auxio.picker.PickerChoices -import org.oxycblt.auxio.picker.PickerDialogFragment - -/** - * A [PickerDialogFragment] intended for when [Artist] navigation is ambiguous. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@AndroidEntryPoint -class ArtistNavigationPickerDialog : PickerDialogFragment() { - private val navModel: NavigationViewModel by activityViewModels() - private val pickerModel: NavigationPickerViewModel 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: ArtistNavigationPickerDialogArgs by navArgs() - - override val titleRes: Int - get() = R.string.lbl_artists - - override val pickerChoices: StateFlow?> - get() = pickerModel.currentArtistChoices - - override fun initChoices() { - pickerModel.setArtistChoiceUid(args.artistUid) - } - - 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/ChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt deleted file mode 100644 index 723777018..000000000 --- a/app/src/main/java/org/oxycblt/auxio/picker/ChoiceAdapter.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * ChoiceAdapter.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.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.* -import org.oxycblt.auxio.util.context -import org.oxycblt.auxio.util.inflater - -/** A [RecyclerView.Adapter] that shows a list */ -class ChoiceAdapter(private val listener: ClickableListListener) : - FlexibleListAdapter>(ChoiceViewHolder.diffCallback()) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - ChoiceViewHolder.from(parent) - - override fun onBindViewHolder(holder: ChoiceViewHolder, position: Int) = - holder.bind(getItem(position), listener) -} - -/** - * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [T] item, for use - * with [ChoiceAdapter]. Use [from] to create an instance. - */ -class ChoiceViewHolder -private constructor(private val binding: ItemPickerChoiceBinding) : - DialogRecyclerView.ViewHolder(binding.root) { - /** - * Bind new data to this instance. - * - * @param music The new [T] to bind. - * @param listener A [ClickableListListener] to bind interactions to. - */ - fun bind(music: T, listener: ClickableListListener) { - listener.bind(music, this) - // ImageGroup is not generic, so we must downcast to specific types for now. - when (music) { - is Song -> binding.pickerImage.bind(music) - is Album -> binding.pickerImage.bind(music) - is Artist -> binding.pickerImage.bind(music) - is Genre -> binding.pickerImage.bind(music) - is Playlist -> binding.pickerImage.bind(music) - } - binding.pickerName.text = music.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) = - ChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) - - /** Get a comparator that can be used with DiffUtil. */ - fun diffCallback() = - object : SimpleDiffCallback() { - override fun areContentsTheSame(oldItem: T, newItem: T) = - oldItem.name == newItem.name - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/PickerChoices.kt b/app/src/main/java/org/oxycblt/auxio/picker/PickerChoices.kt deleted file mode 100644 index b2de58fd5..000000000 --- a/app/src/main/java/org/oxycblt/auxio/picker/PickerChoices.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * PickerChoices.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 org.oxycblt.auxio.music.Music - -/** - * Represents a list of [Music] to show in a picker UI. - * - * @author Alexander Capehart (OxygenCobalt) - */ -interface PickerChoices { - /** The list of choices to show. */ - val choices: List -} diff --git a/app/src/main/java/org/oxycblt/auxio/picker/PickerDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/picker/PickerDialogFragment.kt deleted file mode 100644 index abbf498fe..000000000 --- a/app/src/main/java/org/oxycblt/auxio/picker/PickerDialogFragment.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * PickerDialogFragment.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.navigation.fragment.findNavController -import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.DialogMusicPickerBinding -import org.oxycblt.auxio.list.ClickableListListener -import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.ui.ViewBindingDialogFragment -import org.oxycblt.auxio.util.collectImmediately - -/** - * A [ViewBindingDialogFragment] that acts as the base for a "picker" UI, shown when a given choice - * is ambiguous. - * - * @author Alexander Capehart (OxygenCobalt) - */ -abstract class PickerDialogFragment : - ViewBindingDialogFragment(), ClickableListListener { - // Okay to leak this since the Listener will not be called until after initialization. - private val choiceAdapter = ChoiceAdapter(@Suppress("LeakingThis") this) - - /** The string resource to use in the dialog title. */ - abstract val titleRes: Int - /** The [StateFlow] of choices to show in the picker. */ - abstract val pickerChoices: StateFlow?> - /** Called when the choice list should be initialized from the stored arguments. */ - abstract fun initChoices() - - override fun onCreateBinding(inflater: LayoutInflater) = - DialogMusicPickerBinding.inflate(inflater) - - override fun onConfigDialog(builder: AlertDialog.Builder) { - builder.setTitle(titleRes).setNegativeButton(R.string.lbl_cancel, null) - } - - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { - binding.pickerRecycler.apply { - itemAnimator = null - adapter = choiceAdapter - } - - initChoices() - collectImmediately(pickerChoices) { item -> - if (item != null) { - // Make sure the choices align with any changes in the music library. - choiceAdapter.update(item.choices, UpdateInstructions.Diff) - } else { - // Not showing any choices, navigate up. - findNavController().navigateUp() - } - } - } - - override fun onDestroyBinding(binding: DialogMusicPickerBinding) { - binding.pickerRecycler.adapter = null - } - - override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) { - findNavController().navigateUp() - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt new file mode 100644 index 000000000..9c74577d7 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt @@ -0,0 +1,106 @@ +/* + * 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.playback.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +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.DialogMusicPickerBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.list.recycler.ChoiceViewHolder +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 ArtistPlaybackPickerDialog : + 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: ArtistPlaybackPickerDialogArgs by navArgs() + private val choiceAdapter = ArtistChoiceAdapter(this) + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.lbl_artists).setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogMusicPickerBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.pickerChoiceRecycler.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: DialogMusicPickerBinding) { + 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() + } + + private class ArtistChoiceAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter>(ChoiceViewHolder.diffCallback()) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ChoiceViewHolder = ChoiceViewHolder.from(parent) + + override fun onBindViewHolder(holder: ChoiceViewHolder, position: Int) { + holder.bind(getItem(position), listener) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt new file mode 100644 index 000000000..9dbb271f1 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2022 Auxio Project + * GenrePlaybackPickerDialog.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.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +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.DialogMusicPickerBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.list.recycler.ChoiceViewHolder +import org.oxycblt.auxio.music.Genre +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 [Genre] playback is ambiguous. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class GenrePlaybackPickerDialog : + 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() + private val choiceAdapter = GenreChoiceAdapter(this) + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.lbl_genres).setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogMusicPickerBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.pickerChoiceRecycler.apply { + itemAnimator = null + adapter = choiceAdapter + } + + pickerModel.setPickerSongUid(args.genreUid) + collectImmediately(pickerModel.currentPickerSong) { + if (it != null) { + choiceAdapter.update(it.genres, UpdateInstructions.Replace(0)) + } else { + findNavController().navigateUp() + } + } + } + + override fun onDestroyBinding(binding: DialogMusicPickerBinding) { + 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 = unlikelyToBeNull(pickerModel.currentPickerSong.value) + playbackModel.playFromGenre(song, item) + findNavController().navigateUp() + } + + private class GenreChoiceAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter>(ChoiceViewHolder.diffCallback()) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChoiceViewHolder = + ChoiceViewHolder.from(parent) + + override fun onBindViewHolder(holder: ChoiceViewHolder, position: Int) { + holder.bind(getItem(position), listener) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackPickerViewModel.kt similarity index 51% rename from app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackPickerViewModel.kt index 2fcd20e13..fea0d04da 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackPickerViewModel.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.picker +package org.oxycblt.auxio.playback.dialog import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -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.picker.PickerChoices /** * A [ViewModel] that stores the choices shown in the playback picker dialogs. @@ -34,15 +33,10 @@ import org.oxycblt.auxio.picker.PickerChoices @HiltViewModel class PlaybackPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : ViewModel(), MusicRepository.UpdateListener { - private val _currentArtistChoices = MutableStateFlow(null) + private val _currentPickerSong = MutableStateFlow(null) /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ - val currentArtistChoices: StateFlow - get() = _currentArtistChoices - - private val _currentGenreChoices = MutableStateFlow(null) - /** The current set of [Genre] choices to show in the picker, or null if to show nothing. */ - val currentGenreChoices: StateFlow - get() = _currentGenreChoices + val currentPickerSong: StateFlow + get() = _currentPickerSong init { musicRepository.addUpdateListener(this) @@ -51,14 +45,7 @@ class PlaybackPickerViewModel @Inject constructor(private val musicRepository: M override fun onMusicChanges(changes: MusicRepository.Changes) { if (!changes.deviceLibrary) return val deviceLibrary = musicRepository.deviceLibrary ?: return - _currentArtistChoices.value = - _currentArtistChoices.value?.run { - deviceLibrary.findSong(song.uid)?.let { newSong -> ArtistPlaybackChoices(newSong) } - } - _currentGenreChoices.value = - _currentGenreChoices.value?.run { - deviceLibrary.findSong(song.uid)?.let { newSong -> GenrePlaybackChoices(newSong) } - } + _currentPickerSong.value = _currentPickerSong.value?.run { deviceLibrary.findSong(uid) } } override fun onCleared() { @@ -67,30 +54,11 @@ class PlaybackPickerViewModel @Inject constructor(private val musicRepository: M } /** - * Set the [Music.UID] of the item to show [Artist] choices for. + * 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 setArtistChoiceUid(uid: Music.UID) { - _currentArtistChoices.value = - musicRepository.deviceLibrary?.findSong(uid)?.let { ArtistPlaybackChoices(it) } - } - - /** - * Set the [Music.UID] of the item to show [Genre] choices for. - * - * @param uid The [Music.UID] of the item to show. Must be a [Song]. - */ - fun setGenreChoiceUid(uid: Music.UID) { - _currentGenreChoices.value = - musicRepository.deviceLibrary?.findSong(uid)?.let { GenrePlaybackChoices(it) } + fun setPickerSongUid(uid: Music.UID) { + _currentPickerSong.value = musicRepository.deviceLibrary?.findSong(uid) } } - -data class ArtistPlaybackChoices(val song: Song) : PickerChoices { - override val choices = song.artists -} - -data class GenrePlaybackChoices(val song: Song) : PickerChoices { - override val choices = song.genres -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackPickerDialog.kt deleted file mode 100644 index ff8dbabfa..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackPickerDialog.kt +++ /dev/null @@ -1,63 +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.playback.picker - -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.picker.PickerChoices -import org.oxycblt.auxio.picker.PickerDialogFragment -import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.unlikelyToBeNull - -/** - * An [ArtistPickerDialog] intended for when [Artist] playback is ambiguous. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@AndroidEntryPoint -class ArtistPlaybackPickerDialog : PickerDialogFragment() { - 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: ArtistPlaybackPickerDialogArgs by navArgs() - - override val titleRes: Int - get() = R.string.lbl_artists - - override val pickerChoices: StateFlow?> - get() = pickerModel.currentArtistChoices - - override fun initChoices() { - pickerModel.setArtistChoiceUid(args.artistUid) - } - - 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 = unlikelyToBeNull(pickerModel.currentArtistChoices.value).song - playbackModel.playFromArtist(song, item) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackPickerDialog.kt deleted file mode 100644 index e4af11d41..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackPickerDialog.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * GenrePlaybackPickerDialog.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.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.picker.PickerChoices -import org.oxycblt.auxio.picker.PickerDialogFragment -import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.ViewBindingDialogFragment -import org.oxycblt.auxio.util.unlikelyToBeNull - -/** - * A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@AndroidEntryPoint -class GenrePlaybackPickerDialog : PickerDialogFragment() { - 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() - - override val titleRes: Int - get() = R.string.lbl_genres - - override val pickerChoices: StateFlow?> - get() = pickerModel.currentGenreChoices - - override fun initChoices() { - pickerModel.setGenreChoiceUid(args.genreUid) - } - - override fun onClick(item: Genre, viewHolder: RecyclerView.ViewHolder) { - super.onClick(item, viewHolder) - // User made a choice, play the given song from that genre. - val song = unlikelyToBeNull(pickerModel.currentGenreChoices.value).song - playbackModel.playFromGenre(song, item) - } -} diff --git a/app/src/main/res/layout/dialog_music_picker.xml b/app/src/main/res/layout/dialog_music_picker.xml index f7f81f9af..7137339c7 100644 --- a/app/src/main/res/layout/dialog_music_picker.xml +++ b/app/src/main/res/layout/dialog_music_picker.xml @@ -2,7 +2,7 @@ Date: Fri, 12 May 2023 07:01:04 -0600 Subject: [PATCH 42/88] music: strip articles from extremely short names Strip articles from names that are longer than 2-4 characters, compared the prior limit of 3-5. Resolves #440. --- app/src/main/java/org/oxycblt/auxio/music/info/Name.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 3e61cdf6c..3b7c3bfc7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -183,9 +183,9 @@ private data class IntelligentKnownName(override val raw: String, override val s // Strip any english articles like "the" or "an" from the start, as music // sorting should ignore such when possible. 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) + 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 } } From e01ea25d0b5ecf3371875db98664254d2b195b69 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 12 May 2023 10:39:56 -0600 Subject: [PATCH 43/88] music: add playlist naming flow Add a real playlist naming dialog and UX flow. This is a bit rough at the moment since theres a good amount of nuance here. Should improve as the playlist implementation continues to grow more fleshed out. --- CHANGELOG.md | 1 + .../java/org/oxycblt/auxio/MainFragment.kt | 13 ++- .../auxio/home/FlipFloatingActionButton.kt | 10 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 2 +- .../org/oxycblt/auxio/music/MusicViewModel.kt | 17 ++- .../music/dialog/PlaylistDialogViewModel.kt | 106 ++++++++++++++++++ .../music/dialog/PlaylistNamingDialog.kt | 87 ++++++++++++++ .../{metadata => dialog}/SeparatorsDialog.kt | 15 ++- .../auxio/music/metadata/Separators.kt | 32 ------ .../oxycblt/auxio/music/metadata/TagWorker.kt | 4 + .../dialog/ArtistNavigationPickerDialog.kt | 2 +- ...wModel.kt => NavigationDialogViewModel.kt} | 6 +- .../dialog/ArtistPlaybackPickerDialog.kt | 2 +- .../dialog/GenrePlaybackPickerDialog.kt | 2 +- ...iewModel.kt => PlaybackDialogViewModel.kt} | 4 +- .../res/layout/dialog_playlist_naming.xml | 17 +++ app/src/main/res/navigation/nav_main.xml | 49 +++++--- app/src/main/res/values/strings.xml | 1 + 18 files changed, 302 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistNamingDialog.kt rename app/src/main/java/org/oxycblt/auxio/music/{metadata => dialog}/SeparatorsDialog.kt (92%) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt rename app/src/main/java/org/oxycblt/auxio/navigation/dialog/{NavigationPickerViewModel.kt => NavigationDialogViewModel.kt} (96%) rename app/src/main/java/org/oxycblt/auxio/playback/dialog/{PlaybackPickerViewModel.kt => PlaybackDialogViewModel.kt} (95%) create mode 100644 app/src/main/res/layout/dialog_playlist_naming.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 5581a3903..bbebcf85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ 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") ## 3.0.5 diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 817ef494e..148f665b1 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -40,7 +40,9 @@ import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.dialog.PendingName import org.oxycblt.auxio.navigation.MainNavigationAction import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior @@ -60,8 +62,9 @@ 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 callback = DynamicBackPressedCallback() private var lastInsets: WindowInsets? = null @@ -132,6 +135,7 @@ class MainFragment : collect(navModel.mainNavigationAction.flow, ::handleMainNavigation) collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) + collect(musicModel.pendingPlaylistNaming.flow, ::handlePlaylistNaming) collectImmediately(playbackModel.song, ::updateSong) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) @@ -300,6 +304,13 @@ class MainFragment : } } + private fun handlePlaylistNaming(args: PendingName.Args?) { + if (args != null) { + findNavController().navigateSafe(MainFragmentDirections.actionNamePlaylist(args)) + musicModel.pendingPlaylistNaming.consume() + } + } + private fun handlePlaybackArtistPicker(song: Song?) { if (song != null) { navModel.mainNavigateTo( diff --git a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt index c2b2842a3..2b0cd3d5e 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt @@ -78,10 +78,14 @@ constructor( // Avoid doing a flip if the given config is already being applied. if (tag == iconRes) return tag = iconRes - flipping = true pendingConfig = PendingConfig(iconRes, contentDescriptionRes, clickListener) - // We will re-show the FAB later, assuming that there was not a prior flip operation. - super.hide(FlipVisibilityListener()) + + // 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( 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 e042e59b0..9f2514c71 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -321,7 +321,7 @@ class HomeFragment : } } else { binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) { - musicModel.createPlaylist() + musicModel.createPlaylist("New playlist") } } } 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 13a7b0fc3..6707a6694 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -23,6 +23,9 @@ 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.dialog.PendingName +import org.oxycblt.auxio.util.Event +import org.oxycblt.auxio.util.MutableEvent /** * A [ViewModel] providing data specific to the music loading process. @@ -42,6 +45,9 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos val statistics: StateFlow get() = _statistics + private val _pendingPlaylistNaming = MutableEvent() + val pendingPlaylistNaming: Event = _pendingPlaylistNaming + init { musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -79,12 +85,15 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos } /** - * Create a new generic playlist. + * Create a new generic playlist. This will prompt the user to edit the name before the creation + * finishes. * - * @param name The name of the new playlist. If null, the user will be prompted for a name. + * @param name The preferred name of the new playlist. */ - fun createPlaylist(name: String? = null) { - musicRepository.createPlaylist(name ?: "New playlist", listOf()) + fun createPlaylist(name: String, songs: List = listOf()) { + // TODO: Default to something like "Playlist 1", "Playlist 2", etc. + // TODO: Attempt to unify playlist creation flow with dialog model + _pendingPlaylistNaming.put(PendingName.Args(name, songs.map { it.uid })) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt new file mode 100644 index 000000000..8542b28e1 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistDialogViewModel.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.dialog + +import android.os.Parcelable +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 kotlinx.parcelize.Parcelize +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Song + +/** + * A [ViewModel] managing the state of the playlist editing dialogs. + * @author Alexander Capehart + */ +@HiltViewModel +class PlaylistDialogViewModel @Inject constructor(private val musicRepository: MusicRepository) : + ViewModel(), MusicRepository.UpdateListener { + private val _currentPendingName = MutableStateFlow(null) + val currentPendingName: StateFlow = _currentPendingName + + init { + musicRepository.addUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.deviceLibrary) return + val deviceLibrary = musicRepository.deviceLibrary ?: return + // Update the pending name to reflect new information in the music library. + _currentPendingName.value = + _currentPendingName.value?.let { pendingName -> + PendingName( + pendingName.name, + pendingName.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) + } + } + + override fun onCleared() { + musicRepository.removeUpdateListener(this) + } + + /** + * Update the current [PendingName] based on the given [PendingName.Args]. + * @param args The [PendingName.Args] to update with. + */ + fun setPendingName(args: PendingName.Args) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + val name = + PendingName(args.preferredName, args.songUids.mapNotNull(deviceLibrary::findSong)) + _currentPendingName.value = name + } + + /** + * Update the current [PendingName] based on new user input. + * @param name The new user-inputted name, directly from the UI. + */ + fun updatePendingName(name: String?) { + // Remove any additional whitespace from the string to be consistent with all other + // music items. + val normalized = (name ?: return).trim() + _currentPendingName.value = + _currentPendingName.value?.run { PendingName(normalized, songs) } + } + + /** + * Confirm the current [PendingName] operation and write it to the database. + */ + fun confirmPendingName() { + val pendingName = _currentPendingName.value ?: return + musicRepository.createPlaylist(pendingName.name, pendingName.songs) + _currentPendingName.value = null + } +} + +/** + * Represents a name operation + */ +data class PendingName(val name: String, val songs: List) { + /** + * A [Parcelable] version of [PendingName], to be used as a dialog argument. + * @param preferredName The name to be used initially by the dialog. + * @param songUids The [Music.UID] of any pending [Song]s that will be put in the playlist. + */ + @Parcelize + data class Args(val preferredName: String, val songUids: List) : Parcelable +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistNamingDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistNamingDialog.kt new file mode 100644 index 000000000..6afaf866e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistNamingDialog.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistNamingDialog.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.dialog + +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.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogPlaylistNamingBinding +import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.collectImmediately + +/** + * A dialog allowing the name of a new/existing playlist to be edited. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class PlaylistNamingDialog : ViewBindingDialogFragment() { + // activityViewModels is intentional here as the ViewModel will do work that we + // do not want to cancel after this dialog closes. + private val dialogModel: PlaylistDialogViewModel 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: PlaylistNamingDialogArgs by navArgs() + private var initializedInput = false + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder + .setTitle(R.string.lbl_new_playlist) + .setPositiveButton(R.string.lbl_ok) { _, _ -> dialogModel.confirmPendingName() } + .setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogPlaylistNamingBinding.inflate(inflater) + + override fun onBindingCreated( + binding: DialogPlaylistNamingBinding, + savedInstanceState: Bundle? + ) { + super.onBindingCreated(binding, savedInstanceState) + + binding.playlistName.addTextChangedListener { + dialogModel.updatePendingName(it?.toString()) + } + + dialogModel.setPendingName(args.pendingName) + collectImmediately(dialogModel.currentPendingName, ::updatePendingName) + } + + private fun updatePendingName(pendingName: PendingName?) { + if (pendingName == null) { + findNavController().navigateUp() + return + } + // Make sure we initialize the TextView with the preferred name if we haven't already. + if (!initializedInput) { + requireBinding().playlistName.setText(pendingName.name) + initializedInput = true + } + // Disable the OK button if the name is invalid (empty or whitespace) + (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = + pendingName.name.isNotBlank() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/dialog/SeparatorsDialog.kt similarity index 92% rename from app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/dialog/SeparatorsDialog.kt index 2acf872ba..86e1ebbf4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dialog/SeparatorsDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.metadata +package org.oxycblt.auxio.music.dialog import android.os.Bundle import android.view.LayoutInflater @@ -99,6 +99,19 @@ class SeparatorsDialog : ViewBindingDialogFragment() { return separators } + /** + * Defines the allowed separator characters that can be used to delimit multi-value tags. + * + * @author Alexander Capehart (OxygenCobalt) + */ + 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/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt deleted file mode 100644 index cf42234f4..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * Separators.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.metadata - -/** - * 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 = '&' -} 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 53bacbf08..504491033 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 @@ -169,7 +169,9 @@ private class TagWorkerImpl( (textFrames["TCMP"] ?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"]) ?.let { + // Ignore invalid instances of this tag if (it.size != 1 || it[0] != "1") return@let + // Change the metadata to be a compilation album made by "Various Artists" rawSong.albumArtistNames = rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS } rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES } @@ -262,7 +264,9 @@ private class TagWorkerImpl( // Compilation Flag (comments["compilation"] ?: comments["itunescompilation"])?.let { + // Ignore invalid instances of this tag if (it.size != 1 || it[0] != "1") return@let + // Change the metadata to be a compilation album made by "Various Artists" rawSong.albumArtistNames = rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS } rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES } diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt index c292f00fe..a3a1647a4 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt @@ -48,7 +48,7 @@ import org.oxycblt.auxio.util.collectImmediately class ArtistNavigationPickerDialog : ViewBindingDialogFragment(), ClickableListListener { private val navigationModel: NavigationViewModel by activityViewModels() - private val pickerModel: NavigationPickerViewModel by viewModels() + private val pickerModel: NavigationDialogViewModel 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: ArtistNavigationPickerDialogArgs by navArgs() diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationDialogViewModel.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationPickerViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationDialogViewModel.kt index 193d9553a..9e626b53e 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationDialogViewModel.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * NavigationPickerViewModel.kt is part of Auxio. + * NavigationDialogViewModel.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 @@ -26,12 +26,12 @@ import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.* /** - * A [ViewModel] that stores the current information required for [ArtistNavigationPickerDialog]. + * A [ViewModel] that stores the current information required for navigation dialogs * * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel -class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : +class NavigationDialogViewModel @Inject constructor(private val musicRepository: MusicRepository) : ViewModel(), MusicRepository.UpdateListener { private val _currentArtistChoices = MutableStateFlow(null) /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ diff --git a/app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt index 9c74577d7..d1fc49000 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt @@ -49,7 +49,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull class ArtistPlaybackPickerDialog : ViewBindingDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() - private val pickerModel: PlaybackPickerViewModel by viewModels() + private val pickerModel: PlaybackDialogViewModel 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: ArtistPlaybackPickerDialogArgs by navArgs() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt index 9dbb271f1..d80157002 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt @@ -49,7 +49,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull class GenrePlaybackPickerDialog : ViewBindingDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() - private val pickerModel: PlaybackPickerViewModel by viewModels() + private val pickerModel: PlaybackDialogViewModel 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() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackDialogViewModel.kt similarity index 95% rename from app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackPickerViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackDialogViewModel.kt index fea0d04da..eb97b88f8 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackDialogViewModel.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * PlaybackPickerViewModel.kt is part of Auxio. + * PlaybackDialogViewModel.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 @@ -31,7 +31,7 @@ import org.oxycblt.auxio.music.* * @author OxygenCobalt (Alexander Capehart) */ @HiltViewModel -class PlaybackPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : +class PlaybackDialogViewModel @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. */ diff --git a/app/src/main/res/layout/dialog_playlist_naming.xml b/app/src/main/res/layout/dialog_playlist_naming.xml new file mode 100644 index 000000000..390292f34 --- /dev/null +++ b/app/src/main/res/layout/dialog_playlist_naming.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 7848ff8c7..1be49a828 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -18,26 +18,39 @@ android:id="@+id/action_show_details" app:destination="@id/song_detail_dialog" /> + android:id="@+id/action_name_playlist" + app:destination="@id/playlist_naming_dialog" /> + + android:id="@+id/song_detail_dialog" + android:name="org.oxycblt.auxio.detail.SongDetailDialog" + android:label="song_detail_dialog" + tools:layout="@layout/dialog_song_detail"> + + + + + + + + - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 811c2c573..07d611778 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -78,6 +78,7 @@ Playlist Playlists + New playlist Search From 763061c352da7cd3226aa225a2bebe75fb166361 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 12 May 2023 13:41:39 -0600 Subject: [PATCH 44/88] music: automatically number new playlists Automatically number new playlists, from Playlist 1, Playlist 2, etc. This comes with the additional requirement that all playlists have unique names. --- .../java/org/oxycblt/auxio/MainFragment.kt | 6 +- .../auxio/detail/PlaylistDetailFragment.kt | 1 - .../org/oxycblt/auxio/home/HomeFragment.kt | 2 +- .../java/org/oxycblt/auxio/music/Music.kt | 41 ++++++- .../org/oxycblt/auxio/music/MusicViewModel.kt | 29 ++++- .../auxio/music/device/DeviceMusicImpl.kt | 106 +----------------- ...stNamingDialog.kt => NewPlaylistDialog.kt} | 17 ++- .../music/dialog/PlaylistDialogViewModel.kt | 52 ++++++--- .../oxycblt/auxio/music/user/PlaylistImpl.kt | 5 +- .../oxycblt/auxio/music/user/UserLibrary.kt | 10 ++ .../java/org/oxycblt/auxio/util/LangUtil.kt | 50 +++++++++ ...st_naming.xml => dialog_playlist_name.xml} | 0 app/src/main/res/navigation/nav_main.xml | 12 +- app/src/main/res/values/strings.xml | 2 + 14 files changed, 188 insertions(+), 145 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/dialog/{PlaylistNamingDialog.kt => NewPlaylistDialog.kt} (86%) rename app/src/main/res/layout/{dialog_playlist_naming.xml => dialog_playlist_name.xml} (100%) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 148f665b1..c06b4536b 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -135,7 +135,7 @@ class MainFragment : collect(navModel.mainNavigationAction.flow, ::handleMainNavigation) collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) - collect(musicModel.pendingPlaylistNaming.flow, ::handlePlaylistNaming) + collect(musicModel.pendingNewPlaylist.flow, ::handlePlaylistNaming) collectImmediately(playbackModel.song, ::updateSong) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) @@ -306,8 +306,8 @@ class MainFragment : private fun handlePlaylistNaming(args: PendingName.Args?) { if (args != null) { - findNavController().navigateSafe(MainFragmentDirections.actionNamePlaylist(args)) - musicModel.pendingPlaylistNaming.consume() + findNavController().navigateSafe(MainFragmentDirections.actionNewPlaylist(args)) + musicModel.pendingNewPlaylist.consume() } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index c8d9083d4..a7ae52234 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -138,7 +138,6 @@ class PlaylistDetailFragment : } override fun onPlay() { - // TODO: Handle playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value)) } 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 9f2514c71..c7d6f9cac 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -321,7 +321,7 @@ class HomeFragment : } } else { binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) { - musicModel.createPlaylist("New playlist") + musicModel.createPlaylist(requireContext()) } } } 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 9c3d37a7e..684dd3e00 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -22,6 +22,7 @@ import android.content.Context import android.net.Uri import android.os.Parcelable import androidx.room.TypeConverter +import java.security.MessageDigest import java.util.UUID import kotlin.math.max import kotlinx.parcelize.IgnoredOnParcel @@ -118,13 +119,47 @@ sealed interface Music : Item { companion object { /** - * Creates an Auxio-style [UID] with a [UUID] generated by internal Auxio code. + * Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective, + * unlikely-to-change metadata of the music. * * @param mode The analogous [MusicMode] of the item that created this [UID]. - * @param uuid The generated [UUID] for this item. + * @param updates Block to update the [MessageDigest] hash with the metadata of the + * item. Make sure the metadata hashed semantically aligns with the format + * specification. * @return A new auxio-style [UID]. */ - fun auxio(mode: MusicMode, uuid: UUID) = UID(Format.AUXIO, mode, uuid) + fun auxio(mode: MusicMode, updates: MessageDigest.() -> Unit): UID { + val digest = + MessageDigest.getInstance("SHA-256").run { + updates() + digest() + } + // Convert the digest to a UUID. This does cleave off some of the hash, but this + // is considered okay. + val uuid = + UUID( + digest[0] + .toLong() + .shl(56) + .or(digest[1].toLong().and(0xFF).shl(48)) + .or(digest[2].toLong().and(0xFF).shl(40)) + .or(digest[3].toLong().and(0xFF).shl(32)) + .or(digest[4].toLong().and(0xFF).shl(24)) + .or(digest[5].toLong().and(0xFF).shl(16)) + .or(digest[6].toLong().and(0xFF).shl(8)) + .or(digest[7].toLong().and(0xFF)), + digest[8] + .toLong() + .shl(56) + .or(digest[9].toLong().and(0xFF).shl(48)) + .or(digest[10].toLong().and(0xFF).shl(40)) + .or(digest[11].toLong().and(0xFF).shl(32)) + .or(digest[12].toLong().and(0xFF).shl(24)) + .or(digest[13].toLong().and(0xFF).shl(16)) + .or(digest[14].toLong().and(0xFF).shl(8)) + .or(digest[15].toLong().and(0xFF))) + return UID(Format.AUXIO, mode, uuid) + } /** * Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID 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 6707a6694..259fb5816 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -18,11 +18,13 @@ package org.oxycblt.auxio.music +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.music.dialog.PendingName import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent @@ -45,8 +47,8 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos val statistics: StateFlow get() = _statistics - private val _pendingPlaylistNaming = MutableEvent() - val pendingPlaylistNaming: Event = _pendingPlaylistNaming + private val _pendingNewPlaylist = MutableEvent() + val pendingNewPlaylist: Event = _pendingNewPlaylist init { musicRepository.addUpdateListener(this) @@ -84,16 +86,37 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos musicRepository.requestIndex(false) } + /** + * Create a new generic playlist. This will automatically generate a playlist name and then + * prompt the user to edit the name before the creation finished. + * + * @param context The [Context] required to generate the playlist name. + * @param songs The [Song]s to be contained in the new playlist. + */ + fun createPlaylist(context: Context, songs: List = listOf()) { + 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 }) { + createPlaylist(possibleName, songs) + return + } + ++i + } + } + /** * Create a new generic playlist. This will prompt the user to edit the name before the creation * finishes. * * @param name The preferred name of the new playlist. + * @param songs The [Song]s to be contained in the new playlist. */ fun createPlaylist(name: String, songs: List = listOf()) { // TODO: Default to something like "Playlist 1", "Playlist 2", etc. // TODO: Attempt to unify playlist creation flow with dialog model - _pendingPlaylistNaming.put(PendingName.Args(name, songs.map { it.uid })) + _pendingNewPlaylist.put(PendingName.Args(name, songs.map { it.uid })) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index b0ddb0ca7..e239f97c2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -18,8 +18,6 @@ package org.oxycblt.auxio.music.device -import androidx.annotation.VisibleForTesting -import java.security.MessageDigest import java.util.* import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Sort @@ -35,6 +33,7 @@ import org.oxycblt.auxio.music.metadata.parseMultiValue 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]. @@ -47,7 +46,7 @@ class SongImpl(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) } - ?: createHashedUid(MusicMode.SONGS) { + ?: Music.UID.auxio(MusicMode.SONGS) { // Song UIDs are based on the raw data without parsing so that they remain // consistent across music setting changes. Parents are not held up to the // same standard since grouping is already inherently linked to settings. @@ -231,7 +230,7 @@ class AlbumImpl( override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) } - ?: createHashedUid(MusicMode.ALBUMS) { + ?: Music.UID.auxio(MusicMode.ALBUMS) { // Hash based on only names despite the presence of a date to increase stability. // I don't know if there is any situation where an artist will have two albums with // the exact same name, but if there is, I would love to know. @@ -327,7 +326,7 @@ class ArtistImpl( override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) } - ?: createHashedUid(MusicMode.ARTISTS) { update(rawArtist.name) } + ?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) } override val name = rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) } ?: Name.Unknown(R.string.def_artist) @@ -411,7 +410,7 @@ class GenreImpl( musicSettings: MusicSettings, override val songs: List ) : Genre { - override val uid = createHashedUid(MusicMode.GENRES) { update(rawGenre.name) } + override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) } override val name = rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) } ?: Name.Unknown(R.string.def_genre) @@ -467,98 +466,3 @@ class GenreImpl( return this } } - -/** - * Generate a [Music.UID] derived from the hash of objective music metadata. - * - * @param mode The analogous [MusicMode] of the item that created this [UID]. - * @param updates Block to update the [MessageDigest] hash with the metadata of the item. Make sure - * the metadata hashed semantically aligns with the format specification. - * @return A new [Music.UID] of Auxio format whose [UUID] was derived from the SHA-256 hash of the - * metadata given. - */ -@VisibleForTesting -fun createHashedUid(mode: MusicMode, updates: MessageDigest.() -> Unit): Music.UID { - val digest = - MessageDigest.getInstance("SHA-256").run { - updates() - digest() - } - // Convert the digest to a UUID. This does cleave off some of the hash, but this - // is considered okay. - val uuid = - UUID( - digest[0] - .toLong() - .shl(56) - .or(digest[1].toLong().and(0xFF).shl(48)) - .or(digest[2].toLong().and(0xFF).shl(40)) - .or(digest[3].toLong().and(0xFF).shl(32)) - .or(digest[4].toLong().and(0xFF).shl(24)) - .or(digest[5].toLong().and(0xFF).shl(16)) - .or(digest[6].toLong().and(0xFF).shl(8)) - .or(digest[7].toLong().and(0xFF)), - digest[8] - .toLong() - .shl(56) - .or(digest[9].toLong().and(0xFF).shl(48)) - .or(digest[10].toLong().and(0xFF).shl(40)) - .or(digest[11].toLong().and(0xFF).shl(32)) - .or(digest[12].toLong().and(0xFF).shl(24)) - .or(digest[13].toLong().and(0xFF).shl(16)) - .or(digest[14].toLong().and(0xFF).shl(8)) - .or(digest[15].toLong().and(0xFF))) - return Music.UID.auxio(mode, uuid) -} - -/** - * 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/dialog/PlaylistNamingDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt similarity index 86% rename from app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistNamingDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt index 6afaf866e..944db7fc2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistNamingDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * PlaylistNamingDialog.kt is part of Auxio. + * 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 @@ -27,7 +27,7 @@ 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.DialogPlaylistNamingBinding +import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately @@ -37,13 +37,13 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class PlaylistNamingDialog : ViewBindingDialogFragment() { +class NewPlaylistDialog : ViewBindingDialogFragment() { // activityViewModels is intentional here as the ViewModel will do work that we // do not want to cancel after this dialog closes. private val dialogModel: PlaylistDialogViewModel 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: PlaylistNamingDialogArgs by navArgs() + private val args: NewPlaylistDialogArgs by navArgs() private var initializedInput = false override fun onConfigDialog(builder: AlertDialog.Builder) { @@ -54,12 +54,9 @@ class PlaylistNamingDialog : ViewBindingDialogFragment - PendingName( - pendingName.name, - pendingName.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) + val pendingName = _currentPendingName.value ?: return + + val deviceLibrary = musicRepository.deviceLibrary + val newSongs = + if (changes.deviceLibrary && deviceLibrary != null) { + pendingName.songs.mapNotNull { deviceLibrary.findSong(it.uid) } + } else { + pendingName.songs } + + val userLibrary = musicRepository.userLibrary + val newValid = + if (changes.userLibrary && userLibrary != null) { + validateName(pendingName.name) + } else { + pendingName.valid + } + + _currentPendingName.value = PendingName(pendingName.name, newSongs, newValid) } override fun onCleared() { @@ -61,17 +72,22 @@ class PlaylistDialogViewModel @Inject constructor(private val musicRepository: M /** * Update the current [PendingName] based on the given [PendingName.Args]. + * * @param args The [PendingName.Args] to update with. */ fun setPendingName(args: PendingName.Args) { val deviceLibrary = musicRepository.deviceLibrary ?: return val name = - PendingName(args.preferredName, args.songUids.mapNotNull(deviceLibrary::findSong)) + PendingName( + args.preferredName, + args.songUids.mapNotNull(deviceLibrary::findSong), + validateName(args.preferredName)) _currentPendingName.value = name } /** * Update the current [PendingName] based on new user input. + * * @param name The new user-inputted name, directly from the UI. */ fun updatePendingName(name: String?) { @@ -79,25 +95,31 @@ class PlaylistDialogViewModel @Inject constructor(private val musicRepository: M // music items. val normalized = (name ?: return).trim() _currentPendingName.value = - _currentPendingName.value?.run { PendingName(normalized, songs) } + _currentPendingName.value?.run { PendingName(normalized, songs, validateName(name)) } } - /** - * Confirm the current [PendingName] operation and write it to the database. - */ + /** Confirm the current [PendingName] operation and write it to the database. */ fun confirmPendingName() { val pendingName = _currentPendingName.value ?: return musicRepository.createPlaylist(pendingName.name, pendingName.songs) _currentPendingName.value = null } + + private fun validateName(name: String) = + name.isNotBlank() && musicRepository.userLibrary?.findPlaylist(name) == null } /** - * Represents a name operation + * Represents the current state of a name operation. + * + * @param name The name of the playlist. + * @param songs Any songs that will be in the playlist when added. + * @param valid Whether the current configuration is valid. */ -data class PendingName(val name: String, val songs: List) { +data class PendingName(val name: String, val songs: List, val valid: Boolean) { /** * A [Parcelable] version of [PendingName], to be used as a dialog argument. + * * @param preferredName The name to be used initially by the dialog. * @param songUids The [Music.UID] of any pending [Song]s that will be put in the playlist. */ 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 index cbab2a634..8cf7fe213 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -22,11 +22,12 @@ import java.util.* import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.info.Name +import org.oxycblt.auxio.util.update class PlaylistImpl private constructor( override val uid: Music.UID, - override val name: Name, + override val name: Name.Known, override val songs: List ) : Playlist { override val durationMs = songs.sumOf { it.durationMs } @@ -62,7 +63,7 @@ private constructor( */ fun from(name: String, songs: List, musicSettings: MusicSettings) = PlaylistImpl( - Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()), + Music.UID.auxio(MusicMode.PLAYLISTS) { update(name) }, Name.Known.from(name, null, musicSettings), songs) 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 index 34717d77e..e4ae2ab41 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -44,6 +44,14 @@ interface UserLibrary { */ 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 { /** @@ -104,6 +112,8 @@ private class UserLibraryImpl( override fun findPlaylist(uid: Music.UID) = playlistMap[uid] + override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } + @Synchronized override fun createPlaylist(name: String, songs: List) { val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) 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 1aab1edd1..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], @@ -89,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/res/layout/dialog_playlist_naming.xml b/app/src/main/res/layout/dialog_playlist_name.xml similarity index 100% rename from app/src/main/res/layout/dialog_playlist_naming.xml rename to app/src/main/res/layout/dialog_playlist_name.xml diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 1be49a828..244d189ca 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -18,8 +18,8 @@ android:id="@+id/action_show_details" app:destination="@id/song_detail_dialog" /> + android:id="@+id/action_new_playlist" + app:destination="@id/new_playlist_dialog" /> @@ -42,10 +42,10 @@ + android:id="@+id/new_playlist_dialog" + android:name="org.oxycblt.auxio.music.dialog.NewPlaylistDialog" + android:label="new_playlist_dialog" + tools:layout="@layout/dialog_playlist_name"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07d611778..070b3a927 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -376,6 +376,8 @@ Disc %d + + Playlist %d +%.1f dB From 97705a37e4e610702241c6d209c87f462ab294e0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 12 May 2023 13:45:59 -0600 Subject: [PATCH 45/88] music: remove uid tests Remove Music.UID tests for now in favor of adding them to the main datatype tests instead in the future. --- .../org/oxycblt/auxio/music/MusicViewModel.kt | 1 - .../auxio/music/device/DeviceMusicImplTest.kt | 28 ------------------- 2 files changed, 29 deletions(-) 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 259fb5816..6f0075afb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -114,7 +114,6 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos * @param songs The [Song]s to be contained in the new playlist. */ fun createPlaylist(name: String, songs: List = listOf()) { - // TODO: Default to something like "Playlist 1", "Playlist 2", etc. // TODO: Attempt to unify playlist creation flow with dialog model _pendingNewPlaylist.put(PendingName.Args(name, songs.map { it.uid })) } diff --git a/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt b/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt index 77076670d..9227b93bf 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt @@ -19,38 +19,10 @@ 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.info.Date class DeviceMusicImplTest { - @Test - fun musicUid_auxio() { - val uid = - createHashedUid(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()) - } - @Test fun albumRaw_equals_inconsistentCase() { val a = From e2104c58b8f8fd7ec0abcf7ecfecae621141c6ec Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 12 May 2023 15:42:30 -0600 Subject: [PATCH 46/88] music: clean up playlist name dialog Cleanup the playlist naming dialog to have nicer UX/implementation. --- .../java/org/oxycblt/auxio/MainFragment.kt | 9 +- .../org/oxycblt/auxio/music/MusicViewModel.kt | 8 +- .../auxio/music/dialog/NewPlaylistDialog.kt | 29 +++--- .../music/dialog/PlaylistDialogViewModel.kt | 91 +++++++------------ ...kerDialog.kt => NavigateToArtistDialog.kt} | 6 +- ...ickerDialog.kt => PlayFromArtistDialog.kt} | 6 +- ...PickerDialog.kt => PlayFromGenreDialog.kt} | 6 +- app/src/main/res/navigation/nav_main.xml | 28 +++--- 8 files changed, 78 insertions(+), 105 deletions(-) rename app/src/main/java/org/oxycblt/auxio/navigation/dialog/{ArtistNavigationPickerDialog.kt => NavigateToArtistDialog.kt} (96%) rename app/src/main/java/org/oxycblt/auxio/playback/dialog/{ArtistPlaybackPickerDialog.kt => PlayFromArtistDialog.kt} (96%) rename app/src/main/java/org/oxycblt/auxio/playback/dialog/{GenrePlaybackPickerDialog.kt => PlayFromGenreDialog.kt} (96%) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index c06b4536b..783f16d64 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -42,7 +42,7 @@ import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.dialog.PendingName +import org.oxycblt.auxio.music.dialog.PendingPlaylist import org.oxycblt.auxio.navigation.MainNavigationAction import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior @@ -304,9 +304,10 @@ class MainFragment : } } - private fun handlePlaylistNaming(args: PendingName.Args?) { - if (args != null) { - findNavController().navigateSafe(MainFragmentDirections.actionNewPlaylist(args)) + private fun handlePlaylistNaming(pendingPlaylist: PendingPlaylist?) { + if (pendingPlaylist != null) { + findNavController() + .navigateSafe(MainFragmentDirections.actionNewPlaylist(pendingPlaylist)) musicModel.pendingNewPlaylist.consume() } } 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 6f0075afb..b0cf77587 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -25,7 +25,7 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.dialog.PendingName +import org.oxycblt.auxio.music.dialog.PendingPlaylist import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent @@ -47,8 +47,8 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos val statistics: StateFlow get() = _statistics - private val _pendingNewPlaylist = MutableEvent() - val pendingNewPlaylist: Event = _pendingNewPlaylist + private val _pendingNewPlaylist = MutableEvent() + val pendingNewPlaylist: Event = _pendingNewPlaylist init { musicRepository.addUpdateListener(this) @@ -115,7 +115,7 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos */ fun createPlaylist(name: String, songs: List = listOf()) { // TODO: Attempt to unify playlist creation flow with dialog model - _pendingNewPlaylist.put(PendingName.Args(name, songs.map { it.uid })) + _pendingNewPlaylist.put(PendingPlaylist(name, songs.map { it.uid })) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt index 944db7fc2..8bfcf68e1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt @@ -23,13 +23,13 @@ import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog import androidx.core.widget.addTextChangedListener import androidx.fragment.app.activityViewModels -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.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.unlikelyToBeNull /** * A dialog allowing the name of a new/existing playlist to be edited. @@ -44,7 +44,6 @@ class NewPlaylistDialog : ViewBindingDialogFragment() // 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() - private var initializedInput = false override fun onConfigDialog(builder: AlertDialog.Builder) { builder @@ -59,26 +58,20 @@ class NewPlaylistDialog : ViewBindingDialogFragment() override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) - binding.playlistName.addTextChangedListener { - dialogModel.updatePendingName(it?.toString()) + binding.playlistName.apply { + hint = args.pendingPlaylist.name + addTextChangedListener { + dialogModel.updatePendingName( + (if (it.isNullOrEmpty()) unlikelyToBeNull(hint) else it).toString()) + } } - dialogModel.setPendingName(args.pendingName) - collectImmediately(dialogModel.currentPendingName, ::updatePendingName) + dialogModel.setPendingName(args.pendingPlaylist) + collectImmediately(dialogModel.pendingPlaylistValid, ::updateValid) } - private fun updatePendingName(pendingName: PendingName?) { - if (pendingName == null) { - findNavController().navigateUp() - return - } - // Make sure we initialize the TextView with the preferred name if we haven't already. - if (!initializedInput) { - requireBinding().playlistName.setText(pendingName.name) - initializedInput = true - } + private fun updateValid(valid: Boolean) { // Disable the OK button if the name is invalid (empty or whitespace) - (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = - pendingName.valid + (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = valid } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt index 94eb53280..b0370c618 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.util.logD /** * A [ViewModel] managing the state of the playlist editing dialogs. @@ -37,33 +38,18 @@ import org.oxycblt.auxio.music.Song @HiltViewModel class PlaylistDialogViewModel @Inject constructor(private val musicRepository: MusicRepository) : ViewModel(), MusicRepository.UpdateListener { - private val _currentPendingName = MutableStateFlow(null) - val currentPendingName: StateFlow = _currentPendingName + var pendingPlaylist: PendingPlaylist? = null + private set + + private val _pendingPlaylistValid = MutableStateFlow(false) + val pendingPlaylistValid: StateFlow = _pendingPlaylistValid init { musicRepository.addUpdateListener(this) } override fun onMusicChanges(changes: MusicRepository.Changes) { - val pendingName = _currentPendingName.value ?: return - - val deviceLibrary = musicRepository.deviceLibrary - val newSongs = - if (changes.deviceLibrary && deviceLibrary != null) { - pendingName.songs.mapNotNull { deviceLibrary.findSong(it.uid) } - } else { - pendingName.songs - } - - val userLibrary = musicRepository.userLibrary - val newValid = - if (changes.userLibrary && userLibrary != null) { - validateName(pendingName.name) - } else { - pendingName.valid - } - - _currentPendingName.value = PendingName(pendingName.name, newSongs, newValid) + pendingPlaylist?.let(::validateName) } override fun onCleared() { @@ -71,58 +57,51 @@ class PlaylistDialogViewModel @Inject constructor(private val musicRepository: M } /** - * Update the current [PendingName] based on the given [PendingName.Args]. + * Update the current [PendingPlaylist]. Will do nothing if already equal. * - * @param args The [PendingName.Args] to update with. + * @param pendingPlaylist The [PendingPlaylist] to update with. */ - fun setPendingName(args: PendingName.Args) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - val name = - PendingName( - args.preferredName, - args.songUids.mapNotNull(deviceLibrary::findSong), - validateName(args.preferredName)) - _currentPendingName.value = name + fun setPendingName(pendingPlaylist: PendingPlaylist) { + if (this.pendingPlaylist == pendingPlaylist) return + this.pendingPlaylist = pendingPlaylist + validateName(pendingPlaylist) } /** - * Update the current [PendingName] based on new user input. + * Update the current [PendingPlaylist] based on new user input. * - * @param name The new user-inputted name, directly from the UI. + * @param name The new user-inputted name. */ - fun updatePendingName(name: String?) { + fun updatePendingName(name: String) { + val current = pendingPlaylist ?: return // Remove any additional whitespace from the string to be consistent with all other // music items. - val normalized = (name ?: return).trim() - _currentPendingName.value = - _currentPendingName.value?.run { PendingName(normalized, songs, validateName(name)) } + val new = PendingPlaylist(name.trim(), current.songUids) + pendingPlaylist = new + validateName(new) } - /** Confirm the current [PendingName] operation and write it to the database. */ + /** Confirm the current [PendingPlaylist] operation and write it to the database. */ fun confirmPendingName() { - val pendingName = _currentPendingName.value ?: return - musicRepository.createPlaylist(pendingName.name, pendingName.songs) - _currentPendingName.value = null + val playlist = pendingPlaylist ?: return + val deviceLibrary = musicRepository.deviceLibrary ?: return + musicRepository.createPlaylist( + playlist.name, playlist.songUids.mapNotNull(deviceLibrary::findSong)) } - private fun validateName(name: String) = - name.isNotBlank() && musicRepository.userLibrary?.findPlaylist(name) == null + private fun validateName(pendingPlaylist: PendingPlaylist) { + val userLibrary = musicRepository.userLibrary + _pendingPlaylistValid.value = + pendingPlaylist.name.isNotBlank() && + userLibrary != null && + userLibrary.findPlaylist(pendingPlaylist.name) == null + } } /** - * Represents the current state of a name operation. + * Represents a playlist that is currently being named before actually being completed. * * @param name The name of the playlist. - * @param songs Any songs that will be in the playlist when added. - * @param valid Whether the current configuration is valid. + * @param songUids The [Music.UID]s of the [Song]s to be contained by the playlist. */ -data class PendingName(val name: String, val songs: List, val valid: Boolean) { - /** - * A [Parcelable] version of [PendingName], to be used as a dialog argument. - * - * @param preferredName The name to be used initially by the dialog. - * @param songUids The [Music.UID] of any pending [Song]s that will be put in the playlist. - */ - @Parcelize - data class Args(val preferredName: String, val songUids: List) : Parcelable -} +@Parcelize data class PendingPlaylist(val name: String, val songUids: List) : Parcelable diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigateToArtistDialog.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigateToArtistDialog.kt index a3a1647a4..d90cc1d53 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/ArtistNavigationPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigateToArtistDialog.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2022 Auxio Project - * ArtistNavigationPickerDialog.kt is part of Auxio. + * 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 @@ -45,13 +45,13 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class ArtistNavigationPickerDialog : +class NavigateToArtistDialog : ViewBindingDialogFragment(), ClickableListListener { private val navigationModel: NavigationViewModel by activityViewModels() private val pickerModel: NavigationDialogViewModel 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: ArtistNavigationPickerDialogArgs by navArgs() + private val args: NavigateToArtistDialogArgs by navArgs() private val choiceAdapter = ArtistChoiceAdapter(this) override fun onConfigDialog(builder: AlertDialog.Builder) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromArtistDialog.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromArtistDialog.kt index d1fc49000..0b76b75ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/dialog/ArtistPlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromArtistDialog.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2022 Auxio Project - * ArtistPlaybackPickerDialog.kt is part of Auxio. + * 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 @@ -46,13 +46,13 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class ArtistPlaybackPickerDialog : +class PlayFromArtistDialog : ViewBindingDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val pickerModel: PlaybackDialogViewModel 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: ArtistPlaybackPickerDialogArgs by navArgs() + private val args: PlayFromArtistDialogArgs by navArgs() private val choiceAdapter = ArtistChoiceAdapter(this) override fun onConfigDialog(builder: AlertDialog.Builder) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromGenreDialog.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromGenreDialog.kt index d80157002..008fe1ee1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/dialog/GenrePlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/dialog/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 @@ -46,13 +46,13 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class GenrePlaybackPickerDialog : +class PlayFromGenreDialog : ViewBindingDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val pickerModel: PlaybackDialogViewModel 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() + private val args: PlayFromGenreDialogArgs by navArgs() private val choiceAdapter = GenreChoiceAdapter(this) override fun onConfigDialog(builder: AlertDialog.Builder) { diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 244d189ca..8e3e4761d 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -22,13 +22,13 @@ app:destination="@id/new_playlist_dialog" /> + app:destination="@id/navigate_to_artist_dialog" /> + app:destination="@id/play_from_artist_dialog" /> + app:destination="@id/play_from_genre_dialog" /> + android:name="pendingPlaylist" + app:argType="org.oxycblt.auxio.music.dialog.PendingPlaylist" /> Date: Fri, 12 May 2023 16:28:05 -0600 Subject: [PATCH 47/88] build: update agp to 8.0.1 --- app/build.gradle | 19 ++++++++++--------- .../java/org/oxycblt/auxio/StubTest.kt | 2 +- .../music/dialog/PlaylistDialogViewModel.kt | 1 - build.gradle | 2 +- gradle.properties | 5 ++++- media | 2 +- 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a6a59f192..6586384d1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 } 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/music/dialog/PlaylistDialogViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt index b0370c618..6f4813759 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt @@ -28,7 +28,6 @@ import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.util.logD /** * A [ViewModel] managing the state of the playlist editing dialogs. diff --git a/build.gradle b/build.gradle index bd58d4d81..27c3ff77c 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ 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.18.0" diff --git a/gradle.properties b/gradle.properties index 855d32826..8e89fa623 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,4 +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 \ No newline at end of file +android.enableR8.fullMode=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false \ No newline at end of file diff --git a/media b/media index 5346fe2e5..4ab06ffd6 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 5346fe2e5c812756465e5cb255f388b0db5cf017 +Subproject commit 4ab06ffd6039c038f2995f1a06bafed28bdd9be4 From e71727e18cfb05f72a1857f11bbc912cb2f14957 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 12 May 2023 16:33:49 -0600 Subject: [PATCH 48/88] actions: use jdk 17 Using it as of last commit. --- .github/workflows/android.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 13709e3e8e6ce726f0194c8fe61dc519a54c9118 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 13 May 2023 00:46:08 +0200 Subject: [PATCH 49/88] Translations update from Hosted Weblate (#418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Ukrainian) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (30 of 30 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pa/ * Translated using Weblate (Hindi) Currently translated at 16.4% (43 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Malayalam) Currently translated at 100.0% (30 of 30 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ml/ * Translated using Weblate (Hindi) Currently translated at 100.0% (30 of 30 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hi/ * Translated using Weblate (French) Currently translated at 83.9% (219 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/ * Added translation using Weblate (Persian) * Translated using Weblate (Romanian) Currently translated at 55.5% (145 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ro/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Croatian) Currently translated at 100.0% (30 of 30 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hr/ * Translated using Weblate (Turkish) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/tr/ * Translated using Weblate (Persian) Currently translated at 9.9% (26 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fa/ * Translated using Weblate (Italian) Currently translated at 99.6% (260 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/ * Translated using Weblate (Hindi) Currently translated at 17.6% (46 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Czech) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Malayalam) Currently translated at 32.9% (86 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ml/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Czech) Currently translated at 100.0% (261 of 261 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Russian) Currently translated at 100.0% (263 of 263 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (263 of 263 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (263 of 263 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (263 of 263 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ --------- Co-authored-by: BMN Co-authored-by: ShareASmile Co-authored-by: Martin Derleth Co-authored-by: alex Co-authored-by: Milo Ivir Co-authored-by: Eren İnce Co-authored-by: Alireza Najdshad Co-authored-by: atilluF <110931720+atilluF@users.noreply.github.com> Co-authored-by: Projjal Moitra Co-authored-by: Fjuro Co-authored-by: Raman Co-authored-by: Макар Разин --- app/src/main/res/values-be/strings.xml | 4 +- app/src/main/res/values-cs/strings.xml | 4 +- app/src/main/res/values-fa/strings.xml | 29 +++++++++++++ app/src/main/res/values-fr/strings.xml | 42 ++++++++++++++++++- app/src/main/res/values-hi/strings.xml | 5 ++- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ml/strings.xml | 10 +++++ app/src/main/res/values-ro/strings.xml | 28 ++++++++++++- app/src/main/res/values-ru/strings.xml | 4 +- app/src/main/res/values-tr/strings.xml | 2 + app/src/main/res/values-uk/strings.xml | 20 +++++---- .../metadata/android/hi/full_description.txt | 22 ++++++++++ .../metadata/android/hi/short_description.txt | 1 + .../metadata/android/hr/full_description.txt | 24 ++++++----- .../metadata/android/ml/full_description.txt | 19 +++++++++ .../metadata/android/pa/full_description.txt | 19 +++++++++ .../metadata/android/pa/short_description.txt | 1 + 17 files changed, 207 insertions(+), 29 deletions(-) create mode 100644 app/src/main/res/values-fa/strings.xml create mode 100644 fastlane/metadata/android/hi/full_description.txt create mode 100644 fastlane/metadata/android/hi/short_description.txt create mode 100644 fastlane/metadata/android/ml/full_description.txt create mode 100644 fastlane/metadata/android/pa/full_description.txt create mode 100644 fastlane/metadata/android/pa/short_description.txt diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 0c6f1b845..6a927398e 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -44,7 +44,7 @@ Светлая Жывы сінгл Рэмікс сінгла - Зборнікі рэміксаў + Зборнік рэміксаў Саўндтрэкі Мікстэйпы Мікстэйп @@ -276,4 +276,6 @@ Плэйлісты Адкл. Стварыце новы плэйліст + Плэйліст %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 83068d439..4956c0358 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -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 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-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5dc75c8d8..fe01d0302 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é @@ -199,4 +199,42 @@ 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 + Aucun + 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-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-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1b43c942f..0f15797e2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -275,7 +275,7 @@ Personalizza controlli e comportamento dell\'UI Configura comportamento di suono e riproduzione Discendente - elenco di riproduzione + 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) diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 1ddf8d3c8..c866a1b81 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -87,4 +87,14 @@ %d പാട്ട് %d പാട്ടുകൾ + കലാകാരനിലേക്ക് പോകുക + സവിശേഷതകൾ കാണുക + സ്ഥിതി സംരക്ഷിച്ചു + ഒന്നുമില്ല + അവരോഹണം + സ്ഥിതി പുനഃസ്ഥാപിച്ചു + വിക്കി + സ്ഥിതി മായ്ച്ചു + തത്സമയം + തത്സമയ സമാഹാരം \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 4170d35de..b99bc7023 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,30 @@ 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 + Nu există + 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 5d0db7784..1b85b95a9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -248,7 +248,7 @@ Пользовательское поведение панели воспроизведения Пересканировать музыку Очистить кеш тегов и полностью обновить библиотеку (медленно, но более эффективно) - Сборники ремиксов + Сборник ремиксов %d исполнитель %d исполнителя @@ -285,4 +285,6 @@ Игнорировать такие слова, как «the», при сортировке по имени (лучше всего работает с англоязычной музыкой) Откл. Создать новый плейлист + Новый плейлист + Плейлист %d \ 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 5a558da02..93c6c8733 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -274,4 +274,6 @@ ç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) + Hiçbiri + 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 59b1a49be..53d4c2948 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 Гц @@ -265,14 +265,14 @@ Поведінка Змініть тему та кольори застосунку Налаштуйте елементи керування та поведінку інтерфейсу користувача - Керування завантаженням музики та зображень + Керуйте завантаженням музики та зображень Музика Зображення Відтворення Вирівнювання гучності (ReplayGain) Бібліотека - Наполегливість - Налаштування звуку і поведінки при відтворенні + Стан відтворення + Налаштуйте звук і поведінку при відтворенні Папки За спаданням Зображення списку відтворення для %s @@ -280,6 +280,8 @@ Списки відтворення Немає Інтелектуальне сортування - Ігнорування таких слів, як \"the\", під час сортування за назвою (найкраще працює з англомовною музикою) + Ігнорування таких слів, як \"the\", або цифр під час сортування за назвою (найкраще працює з англомовною музикою) Створити новий список відтворення + Новий список відтворення + Список відтворення %d \ No newline at end of file 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 d928d803c..ee782d658 100644 --- a/fastlane/metadata/android/hr/full_description.txt +++ b/fastlane/metadata/android/hr/full_description.txt @@ -4,17 +4,19 @@ Auxio je lokalni izvođač glazbe s brzim i pouzdanim korisničkim sučeljem/kor - Reprodukcija bazirana na ExoPlayeru - Brzo korisničko sučelje u skladu s najnovijim Materijal dizajnom -- Korisničko iskustvo koje priorizira jednostavnost 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 +- 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 @@ +ਇੱਕ ਸਧਾਰਨ, ਤਰਕਸ਼ੀਲ ਸੰਗੀਤ ਪਲੇਅਰ From 4fe91c25e3d779cd35f76d6fe2cbefd9336fc581 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 13 May 2023 11:39:51 -0600 Subject: [PATCH 50/88] music: streamline new playlist implementation Make the implementation of the playlist creation dialog signifigantly simpler by removing some aspects that don't really need implementation yet. --- .../java/org/oxycblt/auxio/MainFragment.kt | 12 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 2 +- .../org/oxycblt/auxio/music/MusicViewModel.kt | 34 ++-- .../music/{fs => config}/DirectoryAdapter.kt | 3 +- .../music/{fs => config}/MusicDirsDialog.kt | 4 +- .../{dialog => config}/SeparatorsDialog.kt | 2 +- .../music/dialog/PlaylistDialogViewModel.kt | 106 ------------ .../{dialog => picker}/NewPlaylistDialog.kt | 47 ++++-- .../music/picker/PlaylistPickerViewModel.kt | 151 ++++++++++++++++++ .../NavigateToArtistDialog.kt | 6 +- .../NavigationPickerViewModel.kt} | 14 +- .../PlayFromArtistDialog.kt | 4 +- .../{dialog => picker}/PlayFromGenreDialog.kt | 4 +- .../PlaybackPickerViewModel.kt} | 6 +- .../org/oxycblt/auxio/util/FrameworkUtil.kt | 5 +- .../main/res/layout/dialog_playlist_name.xml | 1 + app/src/main/res/navigation/nav_main.xml | 19 ++- 17 files changed, 236 insertions(+), 184 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{fs => config}/DirectoryAdapter.kt (97%) rename app/src/main/java/org/oxycblt/auxio/music/{fs => config}/MusicDirsDialog.kt (98%) rename app/src/main/java/org/oxycblt/auxio/music/{dialog => config}/SeparatorsDialog.kt (99%) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt rename app/src/main/java/org/oxycblt/auxio/music/{dialog => picker}/NewPlaylistDialog.kt (58%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt rename app/src/main/java/org/oxycblt/auxio/navigation/{dialog => picker}/NavigateToArtistDialog.kt (96%) rename app/src/main/java/org/oxycblt/auxio/navigation/{dialog/NavigationDialogViewModel.kt => picker/NavigationPickerViewModel.kt} (90%) rename app/src/main/java/org/oxycblt/auxio/playback/{dialog => picker}/PlayFromArtistDialog.kt (97%) rename app/src/main/java/org/oxycblt/auxio/playback/{dialog => picker}/PlayFromGenreDialog.kt (97%) rename app/src/main/java/org/oxycblt/auxio/playback/{dialog/PlaybackDialogViewModel.kt => picker/PlaybackPickerViewModel.kt} (93%) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 783f16d64..0cb082671 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -42,7 +42,6 @@ import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.dialog.PendingPlaylist import org.oxycblt.auxio.navigation.MainNavigationAction import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior @@ -135,7 +134,7 @@ class MainFragment : collect(navModel.mainNavigationAction.flow, ::handleMainNavigation) collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) - collect(musicModel.pendingNewPlaylist.flow, ::handlePlaylistNaming) + collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) collectImmediately(playbackModel.song, ::updateSong) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) @@ -304,11 +303,12 @@ class MainFragment : } } - private fun handlePlaylistNaming(pendingPlaylist: PendingPlaylist?) { - if (pendingPlaylist != null) { + private fun handleNewPlaylist(songs: List?) { + if (songs != null) { findNavController() - .navigateSafe(MainFragmentDirections.actionNewPlaylist(pendingPlaylist)) - musicModel.pendingNewPlaylist.consume() + .navigateSafe( + MainFragmentDirections.actionNewPlaylist(songs.map { it.uid }.toTypedArray())) + musicModel.newPlaylistSongs.consume() } } 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 c7d6f9cac..e042e59b0 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -321,7 +321,7 @@ class HomeFragment : } } else { binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) { - musicModel.createPlaylist(requireContext()) + musicModel.createPlaylist() } } } 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 b0cf77587..1972209b2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -18,14 +18,11 @@ package org.oxycblt.auxio.music -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.music.dialog.PendingPlaylist import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent @@ -47,8 +44,9 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos val statistics: StateFlow get() = _statistics - private val _pendingNewPlaylist = MutableEvent() - val pendingNewPlaylist: Event = _pendingNewPlaylist + private val _newPlaylistSongs = MutableEvent?>() + /** Flag for opening a dialog to create a playlist of the given [Song]s. */ + val newPlaylistSongs: Event?> = _newPlaylistSongs init { musicRepository.addUpdateListener(this) @@ -87,35 +85,23 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos } /** - * Create a new generic playlist. This will automatically generate a playlist name and then - * prompt the user to edit the name before the creation finished. + * Create a new generic playlist. This will first open a dialog for the user to make a naming + * choice before committing the playlist to the database. * - * @param context The [Context] required to generate the playlist name. * @param songs The [Song]s to be contained in the new playlist. */ - fun createPlaylist(context: Context, songs: List = listOf()) { - 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 }) { - createPlaylist(possibleName, songs) - return - } - ++i - } + fun createPlaylist(songs: List = listOf()) { + _newPlaylistSongs.put(songs) } /** - * Create a new generic playlist. This will prompt the user to edit the name before the creation - * finishes. + * Create a new generic playlist. This will immediately commit the playlist to the database. * - * @param name The preferred name of the new playlist. + * @param name The name of the new playlist. * @param songs The [Song]s to be contained in the new playlist. */ fun createPlaylist(name: String, songs: List = listOf()) { - // TODO: Attempt to unify playlist creation flow with dialog model - _pendingNewPlaylist.put(PendingPlaylist(name, songs.map { it.uid })) + musicRepository.createPlaylist(name, songs) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/config/DirectoryAdapter.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/config/DirectoryAdapter.kt index 5913c2b8c..f9e7b4229 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/config/DirectoryAdapter.kt @@ -16,13 +16,14 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.fs +package org.oxycblt.auxio.music.config import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemMusicDirBinding import org.oxycblt.auxio.list.recycler.DialogRecyclerView +import org.oxycblt.auxio.music.fs.Directory import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/config/MusicDirsDialog.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/config/MusicDirsDialog.kt index 4ecea1336..28a4960ba 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/config/MusicDirsDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.fs +package org.oxycblt.auxio.music.config import android.content.ActivityNotFoundException import android.net.Uri @@ -35,6 +35,8 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.fs.Directory +import org.oxycblt.auxio.music.fs.MusicDirectories import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD diff --git a/app/src/main/java/org/oxycblt/auxio/music/dialog/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/config/SeparatorsDialog.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/dialog/SeparatorsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/config/SeparatorsDialog.kt index 86e1ebbf4..2fc2c5c58 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dialog/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/config/SeparatorsDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.dialog +package org.oxycblt.auxio.music.config import android.os.Bundle import android.view.LayoutInflater diff --git a/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt deleted file mode 100644 index 6f4813759..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistDialogViewModel.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * PlaylistDialogViewModel.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.dialog - -import android.os.Parcelable -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 kotlinx.parcelize.Parcelize -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.Song - -/** - * A [ViewModel] managing the state of the playlist editing dialogs. - * - * @author Alexander Capehart - */ -@HiltViewModel -class PlaylistDialogViewModel @Inject constructor(private val musicRepository: MusicRepository) : - ViewModel(), MusicRepository.UpdateListener { - var pendingPlaylist: PendingPlaylist? = null - private set - - private val _pendingPlaylistValid = MutableStateFlow(false) - val pendingPlaylistValid: StateFlow = _pendingPlaylistValid - - init { - musicRepository.addUpdateListener(this) - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - pendingPlaylist?.let(::validateName) - } - - override fun onCleared() { - musicRepository.removeUpdateListener(this) - } - - /** - * Update the current [PendingPlaylist]. Will do nothing if already equal. - * - * @param pendingPlaylist The [PendingPlaylist] to update with. - */ - fun setPendingName(pendingPlaylist: PendingPlaylist) { - if (this.pendingPlaylist == pendingPlaylist) return - this.pendingPlaylist = pendingPlaylist - validateName(pendingPlaylist) - } - - /** - * Update the current [PendingPlaylist] based on new user input. - * - * @param name The new user-inputted name. - */ - fun updatePendingName(name: String) { - val current = pendingPlaylist ?: return - // Remove any additional whitespace from the string to be consistent with all other - // music items. - val new = PendingPlaylist(name.trim(), current.songUids) - pendingPlaylist = new - validateName(new) - } - - /** Confirm the current [PendingPlaylist] operation and write it to the database. */ - fun confirmPendingName() { - val playlist = pendingPlaylist ?: return - val deviceLibrary = musicRepository.deviceLibrary ?: return - musicRepository.createPlaylist( - playlist.name, playlist.songUids.mapNotNull(deviceLibrary::findSong)) - } - - private fun validateName(pendingPlaylist: PendingPlaylist) { - val userLibrary = musicRepository.userLibrary - _pendingPlaylistValid.value = - pendingPlaylist.name.isNotBlank() && - userLibrary != null && - userLibrary.findPlaylist(pendingPlaylist.name) == null - } -} - -/** - * Represents a playlist that is currently being named before actually being completed. - * - * @param name The name of the playlist. - * @param songUids The [Music.UID]s of the [Song]s to be contained by the playlist. - */ -@Parcelize data class PendingPlaylist(val name: String, val songUids: List) : Parcelable diff --git a/app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt similarity index 58% rename from app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt index 8bfcf68e1..f5ce94c87 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt @@ -16,17 +16,20 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.dialog +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.unlikelyToBeNull @@ -38,9 +41,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull */ @AndroidEntryPoint class NewPlaylistDialog : ViewBindingDialogFragment() { - // activityViewModels is intentional here as the ViewModel will do work that we - // do not want to cancel after this dialog closes. - private val dialogModel: PlaylistDialogViewModel by activityViewModels() + 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() @@ -48,7 +50,16 @@ class NewPlaylistDialog : ViewBindingDialogFragment() override fun onConfigDialog(builder: AlertDialog.Builder) { builder .setTitle(R.string.lbl_new_playlist) - .setPositiveButton(R.string.lbl_ok) { _, _ -> dialogModel.confirmPendingName() } + .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() + } + musicModel.createPlaylist(name, pendingPlaylist.songs) + } .setNegativeButton(R.string.lbl_cancel, null) } @@ -58,20 +69,24 @@ class NewPlaylistDialog : ViewBindingDialogFragment() override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) - binding.playlistName.apply { - hint = args.pendingPlaylist.name - addTextChangedListener { - dialogModel.updatePendingName( - (if (it.isNullOrEmpty()) unlikelyToBeNull(hint) else it).toString()) - } + binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) } + + pickerModel.setPendingPlaylist(requireContext(), args.songUids) + collectImmediately(pickerModel.currentPendingPlaylist, ::updatePendingPlaylist) + collectImmediately(pickerModel.chosenName, ::handleChosenName) + } + + private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) { + if (pendingPlaylist == null) { + findNavController().navigateUp() + return } - dialogModel.setPendingName(args.pendingPlaylist) - collectImmediately(dialogModel.pendingPlaylistValid, ::updateValid) + requireBinding().playlistName.hint = pendingPlaylist.preferredName } - private fun updateValid(valid: Boolean) { - // Disable the OK button if the name is invalid (empty or whitespace) - (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = valid + private fun handleChosenName(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/PlaylistPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt new file mode 100644 index 000000000..33b9fc28d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt @@ -0,0 +1,151 @@ +/* + * 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.music.Music +import org.oxycblt.auxio.music.MusicRepository +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) + val currentPendingPlaylist: StateFlow + get() = _currentPendingPlaylist + + private val _chosenName = MutableStateFlow(ChosenName.Empty) + val chosenName: StateFlow + get() = _chosenName + + init { + musicRepository.addUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + 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) }) + } + } + + 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. + } + } + } + } + + override fun onCleared() { + musicRepository.removeUpdateListener(this) + } + + /** + * Update the current [PendingPlaylist]. Will do nothing if already equal. + * + * @param context [Context] required to generate a playlist name. + * @param songUids The list of [Music.UID] representing the songs to be present in the playlist. + */ + fun setPendingPlaylist(context: Context, songUids: Array) { + if (currentPendingPlaylist.value?.songs?.map { it.uid } == songUids) { + // Nothing to do. + return + } + 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 + } + } + + /** + * 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) + } + } + } + } +} + +/** + * 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 +} diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigateToArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigateToArtistDialog.kt rename to app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt index d90cc1d53..b2be5f806 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigateToArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.navigation.dialog +package org.oxycblt.auxio.navigation.picker import android.os.Bundle import android.view.LayoutInflater @@ -48,7 +48,7 @@ import org.oxycblt.auxio.util.collectImmediately class NavigateToArtistDialog : ViewBindingDialogFragment(), ClickableListListener { private val navigationModel: NavigationViewModel by activityViewModels() - private val pickerModel: NavigationDialogViewModel by viewModels() + 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() @@ -69,7 +69,7 @@ class NavigateToArtistDialog : adapter = choiceAdapter } - pickerModel.setArtistChoiceUid(args.artistUid) + pickerModel.setArtistChoiceUid(args.itemUid) collectImmediately(pickerModel.currentArtistChoices) { if (it != null) { choiceAdapter.update(it.choices, UpdateInstructions.Replace(0)) diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationDialogViewModel.kt b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt similarity index 90% rename from app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationDialogViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt index 9e626b53e..932322c82 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/dialog/NavigationDialogViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * NavigationDialogViewModel.kt is part of Auxio. + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.navigation.dialog +package org.oxycblt.auxio.navigation.picker import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -26,12 +26,12 @@ import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.* /** - * A [ViewModel] that stores the current information required for navigation dialogs + * A [ViewModel] that stores the current information required for navigation picker dialogs * * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel -class NavigationDialogViewModel @Inject constructor(private val musicRepository: MusicRepository) : +class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : ViewModel(), MusicRepository.UpdateListener { private val _currentArtistChoices = MutableStateFlow(null) /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ @@ -68,12 +68,12 @@ class NavigationDialogViewModel @Inject constructor(private val musicRepository: /** * Set the [Music.UID] of the item to show artist choices for. * - * @param uid The [Music.UID] of the item to show. Must be a [Song] or [Album]. + * @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album]. */ - fun setArtistChoiceUid(uid: Music.UID) { + fun setArtistChoiceUid(itemUid: Music.UID) { // Support Songs and Albums, which have parent artists. _currentArtistChoices.value = - when (val music = musicRepository.find(uid)) { + when (val music = musicRepository.find(itemUid)) { is Song -> SongArtistNavigationChoices(music) is Album -> AlbumArtistNavigationChoices(music) else -> null diff --git a/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromArtistDialog.kt rename to app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt index 0b76b75ea..d0907c39f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.dialog +package org.oxycblt.auxio.playback.picker import android.os.Bundle import android.view.LayoutInflater @@ -49,7 +49,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull class PlayFromArtistDialog : ViewBindingDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() - private val pickerModel: PlaybackDialogViewModel by viewModels() + 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() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromGenreDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromGenreDialog.kt rename to app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt index 008fe1ee1..21c81aa25 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlayFromGenreDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.dialog +package org.oxycblt.auxio.playback.picker import android.os.Bundle import android.view.LayoutInflater @@ -49,7 +49,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull class PlayFromGenreDialog : ViewBindingDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() - private val pickerModel: PlaybackDialogViewModel by viewModels() + 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: PlayFromGenreDialogArgs by navArgs() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackDialogViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt similarity index 93% rename from app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackDialogViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt index eb97b88f8..577b93c50 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/dialog/PlaybackDialogViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * PlaybackDialogViewModel.kt is part of Auxio. + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.dialog +package org.oxycblt.auxio.playback.picker import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,7 +31,7 @@ import org.oxycblt.auxio.music.* * @author OxygenCobalt (Alexander Capehart) */ @HiltViewModel -class PlaybackDialogViewModel @Inject constructor(private val musicRepository: MusicRepository) : +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. */ 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..14cd2a20e 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -33,6 +33,7 @@ import androidx.navigation.NavController import androidx.navigation.NavDirections import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding +import java.lang.IllegalArgumentException /** * Get if this [View] contains the given [PointF], with optional leeway. @@ -124,8 +125,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/res/layout/dialog_playlist_name.xml b/app/src/main/res/layout/dialog_playlist_name.xml index 390292f34..e441d1ed4 100644 --- a/app/src/main/res/layout/dialog_playlist_name.xml +++ b/app/src/main/res/layout/dialog_playlist_name.xml @@ -1,5 +1,6 @@ + android:name="songUids" + app:argType="org.oxycblt.auxio.music.Music$UID[]" /> - From 743516592955d717b2d006ca71a5458af3de95b1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 13 May 2023 18:54:55 -0600 Subject: [PATCH 51/88] music: add playlist addition Implement playlist addition and it's UI flow. --- .../java/org/oxycblt/auxio/MainActivity.kt | 1 + .../java/org/oxycblt/auxio/MainFragment.kt | 14 ++- .../auxio/detail/AlbumDetailFragment.kt | 6 + .../auxio/detail/ArtistDetailFragment.kt | 6 + .../auxio/detail/GenreDetailFragment.kt | 5 + .../auxio/detail/PlaylistDetailFragment.kt | 3 +- .../detail/header/DetailHeaderAdapter.kt | 2 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 2 +- .../auxio/home/list/AlbumListFragment.kt | 1 + .../auxio/home/list/ArtistListFragment.kt | 2 + .../auxio/home/list/GenreListFragment.kt | 2 + .../auxio/home/list/PlaylistListFragment.kt | 4 +- .../auxio/home/list/SongListFragment.kt | 2 + .../auxio/image/extractor/Components.kt | 1 + .../org/oxycblt/auxio/list/ListFragment.kt | 12 ++ .../auxio/list/recycler/AuxioRecyclerView.kt | 2 + .../auxio/list/recycler/ViewHolders.kt | 4 + .../auxio/list/selection/SelectionFragment.kt | 16 ++- .../list/selection/SelectionViewModel.kt | 32 +++++- .../oxycblt/auxio/music/MusicRepository.kt | 19 +++- .../org/oxycblt/auxio/music/MusicViewModel.kt | 78 +++++++++++-- .../music/{config => fs}/DirectoryAdapter.kt | 3 +- .../music/{config => fs}/MusicDirsDialog.kt | 4 +- .../{config => metadata}/SeparatorsDialog.kt | 2 +- .../auxio/music/picker/AddToPlaylistDialog.kt | 104 ++++++++++++++++++ .../auxio/music/picker/NewPlaylistDialog.kt | 13 ++- .../music/picker/NewPlaylistFooterAdapter.kt | 83 ++++++++++++++ .../music/picker/PlaylistChoiceAdapter.kt | 83 ++++++++++++++ .../music/picker/PlaylistPickerViewModel.kt | 73 +++++++++++- .../oxycblt/auxio/music/user/UserLibrary.kt | 2 +- .../picker/NavigateToArtistDialog.kt | 2 +- .../picker/NavigationPickerViewModel.kt | 12 +- .../auxio/playback/PlaybackViewModel.kt | 49 +++------ .../oxycblt/auxio/search/SearchFragment.kt | 3 +- .../main/res/layout/dialog_music_picker.xml | 1 + .../res/layout/item_new_playlist_choice.xml | 35 ++++++ app/src/main/res/menu/menu_album_actions.xml | 3 + .../main/res/menu/menu_album_song_actions.xml | 3 + .../res/menu/menu_artist_album_actions.xml | 3 + .../res/menu/menu_artist_song_actions.xml | 3 + app/src/main/res/menu/menu_parent_actions.xml | 3 + app/src/main/res/menu/menu_parent_detail.xml | 3 + .../main/res/menu/menu_playlist_actions.xml | 15 +++ .../main/res/menu/menu_playlist_detail.xml | 9 ++ .../main/res/menu/menu_selection_actions.xml | 3 + app/src/main/res/menu/menu_song_actions.xml | 3 + app/src/main/res/navigation/nav_main.xml | 20 +++- app/src/main/res/values/strings.xml | 4 + 48 files changed, 669 insertions(+), 86 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{config => fs}/DirectoryAdapter.kt (97%) rename app/src/main/java/org/oxycblt/auxio/music/{config => fs}/MusicDirsDialog.kt (98%) rename app/src/main/java/org/oxycblt/auxio/music/{config => metadata}/SeparatorsDialog.kt (99%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt create mode 100644 app/src/main/res/layout/item_new_playlist_choice.xml create mode 100644 app/src/main/res/menu/menu_playlist_actions.xml create mode 100644 app/src/main/res/menu/menu_playlist_detail.xml diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 70e34cb3e..6d9bc9e9b 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -50,6 +50,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * TODO: Unit testing * TODO: Fix UID naming * TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims) + * TODO: Add more logging */ @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 0cb082671..4877ee253 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -135,6 +135,7 @@ class MainFragment : collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) + collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) collectImmediately(playbackModel.song, ::updateSong) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) @@ -261,7 +262,7 @@ class MainFragment : initialNavDestinationChange = true return } - selectionModel.consume() + selectionModel.drop() } private fun handleMainNavigation(action: MainNavigationAction?) { @@ -312,6 +313,15 @@ class MainFragment : } } + 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( @@ -430,7 +440,7 @@ class MainFragment : } // Clear out any prior selections. - if (selectionModel.consume().isNotEmpty()) { + if (selectionModel.drop()) { return } 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 d6992458d..3f08beaab 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -43,6 +43,7 @@ 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.navigation.NavigationViewModel import org.oxycblt.auxio.playback.PlaybackViewModel @@ -61,6 +62,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. @@ -136,6 +138,10 @@ class AlbumDetailFragment : onNavigateToParentArtist() true } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(currentAlbum) + true + } else -> false } } 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 23e1e3456..046c52f29 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -42,6 +42,7 @@ 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 @@ -60,6 +61,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. @@ -131,6 +133,10 @@ class ArtistDetailFragment : requireContext().showToast(R.string.lng_queue_added) true } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(currentArtist) + true + } else -> false } } 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 302c3cfbf..eb08d08a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -56,6 +56,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. @@ -125,6 +126,10 @@ class GenreDetailFragment : requireContext().showToast(R.string.lng_queue_added) true } + R.id.action_playlist_add -> { + musicModel.addToPlaylist(currentGenre) + true + } else -> false } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index a7ae52234..fb3fdd90d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -56,6 +56,7 @@ class PlaylistDetailFragment : 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. @@ -81,7 +82,7 @@ class PlaylistDetailFragment : // --- UI SETUP --- binding.detailToolbar.apply { - inflateMenu(R.menu.menu_parent_detail) + inflateMenu(R.menu.menu_playlist_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@PlaylistDetailFragment) } 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..36a30fe24 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 @@ -51,7 +51,7 @@ abstract class DetailHeaderAdapter(), 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 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 765a39154..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 @@ -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 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 33de26ea3..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,6 +38,7 @@ 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 @@ -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) 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 eca18c2a2..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,6 +38,7 @@ 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 @@ -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) 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 index 5afeb7dc6..a41abdd1d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -36,6 +36,7 @@ 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 @@ -50,6 +51,7 @@ class PlaylistListFragment : 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) @@ -107,7 +109,7 @@ class PlaylistListFragment : } override fun onOpenMenu(item: Playlist, anchor: View) { - openMusicMenu(anchor, R.menu.menu_parent_actions, item) + openMusicMenu(anchor, R.menu.menu_playlist_actions, item) } private fun updatePlaylists(playlists: List) { 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 9b34f8a70..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,6 +39,7 @@ 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 @@ -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 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 f2898f526..bd2f8f1a2 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 @@ -41,6 +41,7 @@ import org.oxycblt.auxio.music.* * @author Alexander Capehart (OxygenCobalt) */ class MusicKeyer : Keyer { + // TODO: Include hashcode of child songs for parents override fun key(data: Music, options: Options) = if (data is Song) { // Group up song covers with album covers for better caching 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 8181fbee0..213b28980 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -99,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( @@ -141,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") } @@ -175,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") } @@ -209,6 +218,9 @@ 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") } 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..b535feba2 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 @@ -33,6 +33,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * - Adapter-based [SpanSizeLookup] implementation * - Automatic [setHasFixedSize] setup * + * FIXME: Broken span configuration + * * @author Alexander Capehart (OxygenCobalt) */ open class AuxioRecyclerView 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 cc0603b4c..45face4aa 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 @@ -353,6 +353,10 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB /** * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [T] item, for use * in choice dialogs. Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Unwind this into specific impls */ class ChoiceViewHolder private constructor(private val binding: ItemPickerChoiceBinding) : 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..bcba5195e 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,6 +36,7 @@ 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 /** @@ -50,7 +52,7 @@ abstract class SelectionFragment : super.onBindingCreated(binding, savedInstanceState) getSelectionToolbar(binding)?.apply { // Add cancel and menu item listeners to manage what occurs with the selection. - setOnSelectionCancelListener { selectionModel.consume() } + setOnSelectionCancelListener { selectionModel.drop() } setOnMenuItemClickListener(this@SelectionFragment) } } @@ -63,21 +65,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/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index 42fea7d41..5c772f519 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 @@ -31,8 +31,12 @@ import org.oxycblt.auxio.music.* * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel -class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) : - ViewModel(), MusicRepository.UpdateListener { +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> @@ -80,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 -> musicSettings.playlistSongSort.songs(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/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index b04f035fc..6ee562a93 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -114,11 +114,19 @@ interface MusicRepository { /** * Create a new [Playlist] of the given [Song]s. * - * @param name The name of the new [Playlist] + * @param name The name of the new [Playlist]. * @param songs The songs to populate the new [Playlist] with. */ fun createPlaylist(name: String, songs: List) + /** + * 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. + */ + fun addToPlaylist(songs: List, playlist: Playlist) + /** * Request that a music loading operation is started by the current [IndexingWorker]. Does * nothing if one is not available. @@ -255,6 +263,15 @@ constructor( } } + override fun addToPlaylist(songs: List, playlist: Playlist) { + val userLibrary = userLibrary ?: return + userLibrary.addToPlaylist(playlist, songs) + for (listener in updateListeners) { + listener.onMusicChanges( + MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) + } + } + override fun requestIndex(withCache: Boolean) { indexingWorker?.requestIndex(withCache) } 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 1972209b2..ea487ba46 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -32,8 +32,12 @@ import org.oxycblt.auxio.util.MutableEvent * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel -class MusicViewModel @Inject constructor(private val musicRepository: MusicRepository) : - ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { +class MusicViewModel +@Inject +constructor( + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings +) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { private val _indexingState = MutableStateFlow(null) /** The current music loading state, or null if no loading is going on. */ @@ -48,6 +52,10 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos /** Flag for opening a dialog to create a playlist of the given [Song]s. */ val newPlaylistSongs: Event?> = _newPlaylistSongs + private val _songsToAdd = MutableEvent?>() + /** Flag for opening a dialog to add the given [Song]s to a playlist. */ + val songsToAdd: Event?> = _songsToAdd + init { musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -85,23 +93,71 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos } /** - * Create a new generic playlist. This will first open a dialog for the user to make a naming - * choice before committing the playlist to the database. + * 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(songs: List = listOf()) { - _newPlaylistSongs.put(songs) + fun createPlaylist(name: String? = null, songs: List = listOf()) { + if (name != null) { + musicRepository.createPlaylist(name, songs) + } else { + _newPlaylistSongs.put(songs) + } } /** - * Create a new generic playlist. This will immediately commit the playlist to the database. + * Add a [Song] to a [Playlist]. * - * @param name The name of the new playlist. - * @param songs The [Song]s to be contained in the new 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 createPlaylist(name: String, songs: List = listOf()) { - musicRepository.createPlaylist(name, songs) + 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) { + musicRepository.addToPlaylist(songs, playlist) + } else { + _songsToAdd.put(songs) + } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/config/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/music/config/DirectoryAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt index f9e7b4229..5913c2b8c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/config/DirectoryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DirectoryAdapter.kt @@ -16,14 +16,13 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.config +package org.oxycblt.auxio.music.fs import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemMusicDirBinding import org.oxycblt.auxio.list.recycler.DialogRecyclerView -import org.oxycblt.auxio.music.fs.Directory import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater diff --git a/app/src/main/java/org/oxycblt/auxio/music/config/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/config/MusicDirsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt index 28a4960ba..4ecea1336 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/config/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.config +package org.oxycblt.auxio.music.fs import android.content.ActivityNotFoundException import android.net.Uri @@ -35,8 +35,6 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.fs.Directory -import org.oxycblt.auxio.music.fs.MusicDirectories import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD diff --git a/app/src/main/java/org/oxycblt/auxio/music/config/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/config/SeparatorsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt index 2fc2c5c58..c413fa2bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/config/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.config +package org.oxycblt.auxio.music.metadata import android.os.Bundle import android.view.LayoutInflater diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt new file mode 100644 index 000000000..5c861bc8a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 Auxio Project + * AddToPlaylistDialog.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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.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.DialogMusicPickerBinding +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 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: AddToPlaylistDialogArgs by navArgs() + private val choiceAdapter = PlaylistChoiceAdapter(this) + private val footerAdapter = NewPlaylistFooterAdapter(this) + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.lbl_playlist_add).setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogMusicPickerBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.pickerChoiceRecycler.apply { + itemAnimator = null + adapter = ConcatAdapter(choiceAdapter, footerAdapter) + } + + // --- VIEWMODEL SETUP --- + pickerModel.setPendingSongs(args.songUids) + collectImmediately(pickerModel.currentPendingSongs, ::updatePendingSongs) + collectImmediately(pickerModel.playlistChoices, ::updatePlaylistChoices) + } + + override fun onDestroyBinding(binding: DialogMusicPickerBinding) { + super.onDestroyBinding(binding) + binding.pickerChoiceRecycler.adapter = null + } + + override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) { + musicModel.addToPlaylist(pickerModel.currentPendingSongs.value ?: return, item.playlist) + pickerModel.confirmPlaylistAddition() + requireContext().showToast(R.string.lng_playlist_added) + findNavController().navigateUp() + } + + override fun onNewPlaylist() { + musicModel.createPlaylist(songs = pickerModel.currentPendingSongs.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/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt index f5ce94c87..27d84fbec 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt @@ -23,7 +23,6 @@ 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 @@ -32,6 +31,7 @@ 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 /** @@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull @AndroidEntryPoint class NewPlaylistDialog : ViewBindingDialogFragment() { private val musicModel: MusicViewModel by activityViewModels() - private val pickerModel: PlaylistPickerViewModel by viewModels() + private val pickerModel: PlaylistPickerViewModel 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: NewPlaylistDialogArgs by navArgs() @@ -58,7 +58,10 @@ class NewPlaylistDialog : ViewBindingDialogFragment() is ChosenName.Empty -> pendingPlaylist.preferredName else -> throw IllegalStateException() } + // TODO: Navigate to playlist if there are songs in it musicModel.createPlaylist(name, pendingPlaylist.songs) + pickerModel.confirmPlaylistCreation() + requireContext().showToast(R.string.lng_playlist_created) } .setNegativeButton(R.string.lbl_cancel, null) } @@ -69,11 +72,13 @@ class NewPlaylistDialog : ViewBindingDialogFragment() 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, ::handleChosenName) + collectImmediately(pickerModel.chosenName, ::updateChosenName) } private fun updatePendingPlaylist(pendingPlaylist: PendingPlaylist?) { @@ -85,7 +90,7 @@ class NewPlaylistDialog : ViewBindingDialogFragment() requireBinding().playlistName.hint = pendingPlaylist.preferredName } - private fun handleChosenName(chosenName: ChosenName) { + 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 index 33b9fc28d..8fb9a9eb1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt @@ -25,8 +25,11 @@ 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 /** @@ -45,11 +48,20 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M val chosenName: StateFlow get() = _chosenName + private val _currentPendingSongs = MutableStateFlow?>(null) + val currentPendingSongs: StateFlow?> + get() = _currentPendingSongs + + private val _playlistChoices = MutableStateFlow>(listOf()) + val playlistChoices: StateFlow> + get() = _playlistChoices + 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 = @@ -58,6 +70,13 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M pendingPlaylist.preferredName, pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) } + _currentPendingSongs.value = + _currentPendingSongs.value?.let { pendingSongs -> + pendingSongs + .mapNotNull { deviceLibrary.findSong(it.uid) } + .ifEmpty { null } + .also { refreshChoicesWith = it } + } } val chosenName = _chosenName.value @@ -69,7 +88,10 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M // Nothing to do. } } + refreshChoicesWith = refreshChoicesWith ?: _currentPendingSongs.value } + + refreshChoicesWith?.let(::refreshPlaylistChoices) } override fun onCleared() { @@ -80,7 +102,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * Update the current [PendingPlaylist]. Will do nothing if already equal. * * @param context [Context] required to generate a playlist name. - * @param songUids The list of [Music.UID] representing the songs to be present in the playlist. + * @param songUids The [Music.UID]s of songs to be present in the playlist. */ fun setPendingPlaylist(context: Context, songUids: Array) { if (currentPendingPlaylist.value?.songs?.map { it.uid } == songUids) { @@ -89,8 +111,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } val deviceLibrary = musicRepository.deviceLibrary ?: return val songs = songUids.mapNotNull(deviceLibrary::findSong) - val userLibrary = musicRepository.userLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return var i = 1 while (true) { val possibleName = context.getString(R.string.fmt_def_playlist, i) @@ -123,6 +145,43 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } } } + + /** Confirm the playlist creation process as completed. */ + fun confirmPlaylistCreation() { + // Confirm any playlist additions if needed, as the creation process may have been started + // by it and is still waiting on a result. + confirmPlaylistAddition() + _currentPendingPlaylist.value = null + _chosenName.value = ChosenName.Empty + } + + /** + * Update the current [Song]s that to show playlist add choices for. Will do nothing if already + * equal. + * + * @param songUids The [Music.UID]s of songs to add to a playlist. + */ + fun setPendingSongs(songUids: Array) { + if (currentPendingSongs.value?.map { it.uid } == songUids) return + val deviceLibrary = musicRepository.deviceLibrary ?: return + val songs = songUids.mapNotNull(deviceLibrary::findSong) + _currentPendingSongs.value = songs + refreshPlaylistChoices(songs) + } + + /** Mark the addition process as complete. */ + fun confirmPlaylistAddition() { + _currentPendingSongs.value = null + } + + private fun refreshPlaylistChoices(songs: List) { + val userLibrary = musicRepository.userLibrary ?: return + _playlistChoices.value = + Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map { + val songSet = it.songs.toSet() + PlaylistChoice(it, songs.all(songSet::contains)) + } + } } /** @@ -149,3 +208,13 @@ sealed interface 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/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index e4ae2ab41..4e5239f7d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -107,7 +107,7 @@ private class UserLibraryImpl( init { // TODO: Actually read playlists - createPlaylist("Playlist 1", deviceLibrary.songs.slice(58..100)) + createPlaylist("Playlist 1", deviceLibrary.songs.slice(58..200)) } override fun findPlaylist(uid: Music.UID) = playlistMap[uid] 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 index b2be5f806..1d3f83543 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt @@ -70,7 +70,7 @@ class NavigateToArtistDialog : } pickerModel.setArtistChoiceUid(args.itemUid) - collectImmediately(pickerModel.currentArtistChoices) { + collectImmediately(pickerModel.artistChoices) { if (it != null) { choiceAdapter.update(it.choices, UpdateInstructions.Replace(0)) } else { 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 index 932322c82..b09b74ae9 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt @@ -33,10 +33,10 @@ import org.oxycblt.auxio.music.* @HiltViewModel class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : ViewModel(), MusicRepository.UpdateListener { - private val _currentArtistChoices = MutableStateFlow(null) + private val _artistChoices = MutableStateFlow(null) /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ - val currentArtistChoices: StateFlow - get() = _currentArtistChoices + val artistChoices: StateFlow + get() = _artistChoices init { musicRepository.addUpdateListener(this) @@ -46,8 +46,8 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: if (!changes.deviceLibrary) return val deviceLibrary = musicRepository.deviceLibrary ?: return // Need to sanitize different items depending on the current set of choices. - _currentArtistChoices.value = - when (val choices = _currentArtistChoices.value) { + _artistChoices.value = + when (val choices = _artistChoices.value) { is SongArtistNavigationChoices -> deviceLibrary.findSong(choices.song.uid)?.let { SongArtistNavigationChoices(it) @@ -72,7 +72,7 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: */ fun setArtistChoiceUid(itemUid: Music.UID) { // Support Songs and Albums, which have parent artists. - _currentArtistChoices.value = + _artistChoices.value = when (val music = musicRepository.find(itemUid)) { is Song -> SongArtistNavigationChoices(music) is Album -> AlbumArtistNavigationChoices(music) 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 d94e38621..81ea0d121 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -256,12 +256,11 @@ constructor( fun play(playlist: Playlist) = playImpl(null, playlist, false) /** - * Play a [Music] selection. + * Play a list of [Song]s. * - * @param selection The selection to play. + * @param songs The [Song]s to play. */ - fun play(selection: List) = - playbackManager.play(null, null, selectionToSongs(selection), false) + fun play(songs: List) = playbackManager.play(null, null, songs, false) /** * Shuffle an [Album]. @@ -292,12 +291,11 @@ constructor( fun shuffle(playlist: Playlist) = playImpl(null, playlist, true) /** - * Shuffle a [Music] selection. + * Shuffle a list of [Song]s. * - * @param selection The selection to shuffle. + * @param songs The [Song]s to shuffle. */ - fun shuffle(selection: List) = - playbackManager.play(null, null, selectionToSongs(selection), true) + fun shuffle(songs: List) = playbackManager.play(null, null, songs, true) private fun playImpl( song: Song?, @@ -400,12 +398,12 @@ constructor( } /** - * Add a selection to the top of the queue. + * Add [Song]s to the top of the queue. * - * @param selection The [Music] selection to add. + * @param songs The [Song]s to add. */ - fun playNext(selection: List) { - playbackManager.playNext(selectionToSongs(selection)) + fun playNext(songs: List) { + playbackManager.playNext(songs) } /** @@ -454,12 +452,12 @@ constructor( } /** - * Add a selection to the end of the queue. + * Add [Song]s to the end of the queue. * - * @param selection The [Music] selection to add. + * @param songs The [Song]s to add. */ - fun addToQueue(selection: List) { - playbackManager.addToQueue(selectionToSongs(selection)) + fun addToQueue(songs: List) { + playbackManager.addToQueue(songs) } // --- STATUS FUNCTIONS --- @@ -522,23 +520,4 @@ constructor( 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 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 -> musicSettings.playlistSongSort.songs(it.songs) - } - } - } } 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 6bf06014d..7846710b9 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -53,6 +53,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) @@ -150,7 +151,7 @@ class SearchFragment : ListFragment() { is Album -> openMusicMenu(anchor, R.menu.menu_album_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_parent_actions, item) + is Playlist -> openMusicMenu(anchor, R.menu.menu_playlist_actions, item) } } diff --git a/app/src/main/res/layout/dialog_music_picker.xml b/app/src/main/res/layout/dialog_music_picker.xml index 7137339c7..8c1d82ee3 100644 --- a/app/src/main/res/layout/dialog_music_picker.xml +++ b/app/src/main/res/layout/dialog_music_picker.xml @@ -1,4 +1,5 @@ + + + + + + + + diff --git a/app/src/main/res/menu/menu_album_actions.xml b/app/src/main/res/menu/menu_album_actions.xml index 8fcb05935..7292d9016 100644 --- a/app/src/main/res/menu/menu_album_actions.xml +++ b/app/src/main/res/menu/menu_album_actions.xml @@ -15,4 +15,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_parent_actions.xml b/app/src/main/res/menu/menu_parent_actions.xml index df535f77d..4e6112035 100644 --- a/app/src/main/res/menu/menu_parent_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_parent_detail.xml b/app/src/main/res/menu/menu_parent_detail.xml index e480d9191..3a2225ea3 100644 --- a/app/src/main/res/menu/menu_parent_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_playlist_actions.xml b/app/src/main/res/menu/menu_playlist_actions.xml new file mode 100644 index 000000000..df535f77d --- /dev/null +++ b/app/src/main/res/menu/menu_playlist_actions.xml @@ -0,0 +1,15 @@ + + + + + + + \ 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..e480d9191 --- /dev/null +++ b/app/src/main/res/menu/menu_playlist_detail.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file 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_main.xml b/app/src/main/res/navigation/nav_main.xml index 01ca95bc8..c0c921371 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -20,6 +20,9 @@ + @@ -51,6 +54,19 @@ app:argType="org.oxycblt.auxio.music.Music$UID[]" /> + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 070b3a927..32f8ba50d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,8 @@ Play next Add to queue + Add to playlist + Go to artist Go to album View properties @@ -160,6 +162,8 @@ Loading your music library… Monitoring your music library for changes… Added to queue + Playlist created + Added to playlist Developed by Alexander Capehart Search your library… From 949a9c879c5da6502dacb8091ec78d2edfb31738 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 13 May 2023 19:31:28 -0600 Subject: [PATCH 52/88] detail: improve playlist presentation Improve playlist presentation in the detail views, especially when it is empty. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 15 +++++++----- .../header/ArtistDetailHeaderAdapter.kt | 24 ++++++++++--------- .../header/PlaylistDetailHeaderAdapter.kt | 23 ++++++++++++++---- .../auxio/list/recycler/ViewHolders.kt | 23 ++++++++++-------- app/src/main/res/values/strings.xml | 1 + .../auxio/music/FakeMusicRepository.kt | 4 ++++ .../oxycblt/auxio/music/MusicViewModelTest.kt | 6 ++--- 7 files changed, 62 insertions(+), 34 deletions(-) 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 f176d85a0..86d4aee4c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -46,6 +46,8 @@ import org.oxycblt.auxio.util.* * [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the * current item they are showing, sub-data to display, and configuration. * + * FIXME: Need to do direct item comparison in equality checks, or reset on navigation. + * * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel @@ -416,15 +418,16 @@ constructor( private fun refreshPlaylistList(playlist: Playlist, replace: Boolean = false) { logD("Refreshing playlist list") + var instructions: UpdateInstructions = UpdateInstructions.Diff val list = mutableListOf() - list.add(SortHeader(R.string.lbl_songs)) - val instructions = + + if (playlist.songs.isNotEmpty()) { + list.add(SortHeader(R.string.lbl_songs)) if (replace) { - UpdateInstructions.Replace(list.size) - } else { - UpdateInstructions.Diff + instructions = UpdateInstructions.Replace(list.size) } - list.addAll(playlistSongSort.songs(playlist.songs)) + list.addAll(playlistSongSort.songs(playlist.songs)) + } _playlistInstructions.put(instructions) _playlistList.value = list } 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 813615fc3..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 @@ -65,6 +65,17 @@ private constructor(private val binding: ItemDetailHeaderBinding) : binding.detailType.text = binding.context.getString(R.string.lbl_artist) 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 binding.detailSubhead.apply { @@ -72,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 @@ -88,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/PlaylistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt index e0825af8b..bfa048d01 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt @@ -65,11 +65,26 @@ private constructor(private val binding: ItemDetailHeaderBinding) : binding.detailName.text = playlist.name.resolve(binding.context) // Nothing about a playlist is applicable to the sub-head text. binding.detailSubhead.isVisible = false + // The song count of the playlist maps to the info text. - binding.detailInfo.text = - binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size) - binding.detailPlayButton.setOnClickListener { listener.onPlay() } - binding.detailShuffleButton.setOnClickListener { listener.onShuffle() } + binding.detailInfo.apply { + isVisible = true + 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) + } + } + + binding.detailPlayButton.apply { + isEnabled = playlist.songs.isNotEmpty() + setOnClickListener { listener.onPlay() } + } + binding.detailShuffleButton.apply { + isEnabled = playlist.songs.isNotEmpty() + setOnClickListener { listener.onShuffle() } + } } companion object { 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 45face4aa..62fffeb54 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 @@ -157,15 +157,14 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin binding.parentImage.bind(artist) 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) { @@ -275,7 +274,11 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind binding.parentImage.bind(playlist) binding.parentName.text = playlist.name.resolve(binding.context) binding.parentInfo.text = - binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size) + 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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32f8ba50d..cbc3db5e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -326,6 +326,7 @@ Unknown genre No date No track + No songs No music playing diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt index 2d113a3f1..a09aa1722 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt @@ -62,6 +62,10 @@ open class FakeMusicRepository : MusicRepository { throw NotImplementedError() } + override fun addToPlaylist(songs: List, playlist: Playlist) { + throw NotImplementedError() + } + override fun requestIndex(withCache: Boolean) { throw NotImplementedError() } 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 b1540283e..8ad02dbb6 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt @@ -32,7 +32,7 @@ class MusicViewModelTest { TestMusicRepository().apply { indexingState = IndexingState.Indexing(IndexingProgress.Indeterminate) } - val musicViewModel = MusicViewModel(indexer) + val musicViewModel = MusicViewModel(indexer, FakeMusicSettings()) assertTrue(indexer.updateListener is MusicViewModel) assertTrue(indexer.indexingListener is MusicViewModel) assertEquals( @@ -47,7 +47,7 @@ class MusicViewModelTest { @Test fun statistics() { val musicRepository = TestMusicRepository() - val musicViewModel = MusicViewModel(musicRepository) + val musicViewModel = MusicViewModel(musicRepository, FakeMusicSettings()) assertEquals(null, musicViewModel.statistics.value) musicRepository.deviceLibrary = TestDeviceLibrary() assertEquals( @@ -64,7 +64,7 @@ class MusicViewModelTest { @Test fun requests() { val indexer = TestMusicRepository() - val musicViewModel = MusicViewModel(indexer) + val musicViewModel = MusicViewModel(indexer, FakeMusicSettings()) musicViewModel.refresh() musicViewModel.rescan() assertEquals(listOf(true, false), indexer.requests) From d0444bb41d62ed2eb8e985a62e4962c29f6d1c21 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 13 May 2023 20:05:04 -0600 Subject: [PATCH 53/88] detail: add duration indicator to playlist Add a duration indicator alongside the song count in the playlist detail header. --- .../auxio/detail/header/PlaylistDetailHeaderAdapter.kt | 6 +++++- .../org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt | 2 +- app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt index bfa048d01..4880c5cab 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt @@ -25,6 +25,7 @@ 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.playback.formatDurationMs import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.inflater @@ -71,7 +72,10 @@ private constructor(private val binding: ItemDetailHeaderBinding) : isVisible = true text = if (playlist.songs.isNotEmpty()) { - binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size) + binding.context.getString( + R.string.fmt_two, + binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size), + playlist.durationMs.formatDurationMs(true)) } else { binding.context.getString(R.string.def_song_count) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt index 5c861bc8a..eb81775c2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -55,7 +55,7 @@ class AddToPlaylistDialog : private val footerAdapter = NewPlaylistFooterAdapter(this) override fun onConfigDialog(builder: AlertDialog.Builder) { - builder.setTitle(R.string.lbl_playlist_add).setNegativeButton(R.string.lbl_cancel, null) + builder.setTitle(R.string.lbl_playlists).setNegativeButton(R.string.lbl_cancel, null) } override fun onCreateBinding(inflater: LayoutInflater) = 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 5d8e54318..fa3cf2dea 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -33,6 +33,8 @@ import org.oxycblt.auxio.music.info.Name * Implements the fuzzy-ish searching algorithm used in the search view. * * @author Alexander Capehart + * + * TODO: Add playlists */ interface SearchEngine { /** From 956b6fda2be3d705ed29b84a3fa86463ada1cd3b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 14 May 2023 09:59:08 -0600 Subject: [PATCH 54/88] all: fix inspections Fix miscellanious code inspections. --- .../auxio/image/extractor/CoverExtractor.kt | 1 - .../main/java/org/oxycblt/auxio/list/Sort.kt | 6 +++--- .../main/java/org/oxycblt/auxio/music/Music.kt | 2 +- .../org/oxycblt/auxio/music/MusicRepository.kt | 1 - .../auxio/music/device/DeviceMusicImpl.kt | 1 - .../org/oxycblt/auxio/music/device/RawMusic.kt | 18 +++++++++--------- .../oxycblt/auxio/music/metadata/TagUtil.kt | 4 ++-- .../auxio/music/system/IndexerService.kt | 2 +- .../oxycblt/auxio/music/user/PlaylistImpl.kt | 1 - .../auxio/settings/BasePreferenceFragment.kt | 2 +- .../main/res/layout/dialog_playlist_name.xml | 1 - app/src/main/res/layout/dialog_song_detail.xml | 1 - .../main/res/layout/fragment_preferences.xml | 4 +++- .../res/layout/item_new_playlist_choice.xml | 1 - app/src/main/res/values-sr/strings.xml | 2 +- 15 files changed, 21 insertions(+), 26 deletions(-) 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 edb62ddbd..a89931fba 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 @@ -122,7 +122,6 @@ 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) } 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 f8e73adc7..808a8d150 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt @@ -222,7 +222,7 @@ data class Sort(val mode: Mode, val direction: Direction) { /** * Sort by the item's name. * - * @see Music.sortName + * @see Music.name */ object ByName : Mode { override val intCode: Int @@ -520,7 +520,7 @@ 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 [Direction] to sort in. + * @param direction The [Sort.Direction] to sort in. * @see compareBy * @see compareByDescending */ @@ -536,7 +536,7 @@ private inline fun > compareByDynamic( /** * Utility function to create a [Comparator] in a dynamic way determined by [direction] * - * @param direction The [Direction] to sort in. + * @param direction The [Sort.Direction] to sort in. * @param comparator A [Comparator] to wrap. * @return A new [Comparator] with the specified configuration. * @see compareBy 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 684dd3e00..bfc48927b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -350,7 +350,7 @@ interface Playlist : MusicParent { } /** - * Run [Music.resolveName] on each instance in the given list and concatenate them into a [String] + * Run [Name.resolve] on each instance in the given list and concatenate them into a [String] * in a localized manner. * * @param context [Context] required 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 6ee562a93..3b6291056 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -27,7 +27,6 @@ import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel -import org.oxycblt.auxio.R import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.device.RawSong diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index e239f97c2..1de1db4a8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.music.device -import java.util.* import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index 6cb66f0ae..bf3bb5794 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -52,15 +52,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, @@ -103,9 +103,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?, @@ -145,9 +145,9 @@ class RawAlbum( 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: @@ -185,7 +185,7 @@ class RawArtist( * @author Alexander Capehart (OxygenCobalt) */ class RawGenre( - /** @see Music.rawName */ + /** @see Music.name */ val name: String? = null ) { 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 a8a1ba634..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 @@ -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/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 72444399c..56514f7dd 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 @@ -45,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. 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 index 8cf7fe213..afd89de1d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.music.user -import java.util.* import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.info.Name 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/res/layout/dialog_playlist_name.xml b/app/src/main/res/layout/dialog_playlist_name.xml index e441d1ed4..5d237fd74 100644 --- a/app/src/main/res/layout/dialog_playlist_name.xml +++ b/app/src/main/res/layout/dialog_playlist_name.xml @@ -7,7 +7,6 @@ android:paddingEnd="@dimen/spacing_mid_large" android:paddingStart="@dimen/spacing_mid_large" xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" app:hintEnabled="false"> + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" + tools:targetApi="n" /> diff --git a/app/src/main/res/layout/item_new_playlist_choice.xml b/app/src/main/res/layout/item_new_playlist_choice.xml index ccba119fa..471c9c739 100644 --- a/app/src/main/res/layout/item_new_playlist_choice.xml +++ b/app/src/main/res/layout/item_new_playlist_choice.xml @@ -1,7 +1,6 @@ - \ No newline at end of file + \ No newline at end of file From dcc82608bd7cd0d807c43da7c48086f581ad950d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 17 May 2023 11:04:35 -0600 Subject: [PATCH 55/88] music: add playlist deletion Add basic playlist deletion flow. No confirmation dialog yet, that will need to be implemented later. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 62 ++++++++++--------- .../auxio/detail/PlaylistDetailFragment.kt | 4 ++ .../org/oxycblt/auxio/list/ListFragment.kt | 3 + .../java/org/oxycblt/auxio/music/Music.kt | 4 +- .../oxycblt/auxio/music/MusicRepository.kt | 16 +++++ .../org/oxycblt/auxio/music/MusicViewModel.kt | 11 ++++ .../auxio/music/device/DeviceLibrary.kt | 2 + .../auxio/music/metadata/SeparatorsDialog.kt | 5 -- .../oxycblt/auxio/music/metadata/TagWorker.kt | 3 + .../oxycblt/auxio/music/user/UserLibrary.kt | 12 ++++ .../main/res/menu/menu_playlist_actions.xml | 3 + .../main/res/menu/menu_playlist_detail.xml | 3 + app/src/main/res/values/strings.xml | 1 + 13 files changed, 92 insertions(+), 37 deletions(-) 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 86d4aee4c..dffde42ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -186,42 +186,44 @@ constructor( } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (!changes.deviceLibrary) return - val deviceLibrary = musicRepository.deviceLibrary ?: return - val userLibrary = musicRepository.userLibrary ?: return - // 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 = deviceLibrary.findSong(song.uid)?.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 = 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 playlist = currentPlaylist.value - if (playlist != null) { - _currentPlaylist.value = - userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList) + val userLibrary = musicRepository.userLibrary + if (changes.userLibrary && userLibrary != null) { + val playlist = currentPlaylist.value + if (playlist != null) { + _currentPlaylist.value = + userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index fb3fdd90d..d1214f6b2 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -126,6 +126,10 @@ class PlaylistDetailFragment : requireContext().showToast(R.string.lng_queue_added) true } + R.id.action_delete -> { + musicModel.deletePlaylist(currentPlaylist) + true + } else -> false } } 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 213b28980..f2e050e40 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -255,6 +255,9 @@ abstract class ListFragment : playbackModel.addToQueue(playlist) requireContext().showToast(R.string.lng_queue_added) } + R.id.action_delete -> { + musicModel.deletePlaylist(playlist) + } else -> { error("Unexpected menu item selected") } 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 bfc48927b..37cbf83bc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -350,8 +350,8 @@ interface Playlist : MusicParent { } /** - * Run [Name.resolve] 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. 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 3b6291056..99e93ffb0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -118,6 +118,13 @@ interface MusicRepository { */ fun createPlaylist(name: String, songs: List) + /** + * Delete a [Playlist]. + * + * @param playlist The playlist to delete. + */ + fun deletePlaylist(playlist: Playlist) + /** * Add the given [Song]s to a [Playlist]. * @@ -262,6 +269,15 @@ constructor( } } + override fun deletePlaylist(playlist: Playlist) { + val userLibrary = userLibrary ?: return + userLibrary.deletePlaylist(playlist) + for (listener in updateListeners) { + listener.onMusicChanges( + MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) + } + } + override fun addToPlaylist(songs: List, playlist: Playlist) { val userLibrary = userLibrary ?: return userLibrary.addToPlaylist(playlist, songs) 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 ea487ba46..df53b581d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -106,6 +106,17 @@ constructor( } } + /** + * Delete a [Playlist]. + * + * @param playlist The playlist to delete. + * + * TODO: Prompt the user before deleting. + */ + fun deletePlaylist(playlist: Playlist) { + musicRepository.deletePlaylist(playlist) + } + /** * Add a [Song] to a [Playlist]. * diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index f292303b5..44ac4e99d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -210,6 +210,8 @@ private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings albums: List, settings: MusicSettings ): List { + // TODO: Debug an issue with my current library config that results in two duplicate + // artists. // Add every raw artist credited to each Song/Album to the grouping. This way, // different multi-artist combinations are not treated as different artists. val musicByArtist = mutableMapOf>() 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 c413fa2bd..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,11 +99,6 @@ class SeparatorsDialog : ViewBindingDialogFragment() { return separators } - /** - * Defines the allowed separator characters that can be used to delimit multi-value tags. - * - * @author Alexander Capehart (OxygenCobalt) - */ private object Separators { const val COMMA = ',' const val SEMICOLON = ';' 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 504491033..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 @@ -130,6 +130,9 @@ private class TagWorkerImpl( // 3. ID3v2.4 Release Date, as it is the second most common date type // 4. ID3v2.3 Original Date, as it is like #1 // 5. ID3v2.3 Release Year, as it is the most common date type + // TODO: Show original and normal dates side-by-side + // TODO: Handle dates that are in "January" because the actual specific release date + // isn't known? (textFrames["TDOR"]?.run { Date.from(first()) } ?: textFrames["TDRC"]?.run { Date.from(first()) } ?: textFrames["TDRL"]?.run { Date.from(first()) } 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 index 4e5239f7d..f2df2d1a5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -80,6 +80,13 @@ interface MutableUserLibrary : UserLibrary { */ fun createPlaylist(name: String, songs: List) + /** + * Delete a [Playlist]. + * + * @param playlist The playlist to delete. + */ + fun deletePlaylist(playlist: Playlist) + /** * Add [Song]s to a [Playlist]. * @@ -120,6 +127,11 @@ private class UserLibraryImpl( playlistMap[playlistImpl.uid] = playlistImpl } + @Synchronized + override fun deletePlaylist(playlist: Playlist) { + playlistMap.remove(playlist.uid) + } + @Synchronized override fun addToPlaylist(playlist: Playlist, songs: List) { val playlistImpl = diff --git a/app/src/main/res/menu/menu_playlist_actions.xml b/app/src/main/res/menu/menu_playlist_actions.xml index df535f77d..4f852e49f 100644 --- a/app/src/main/res/menu/menu_playlist_actions.xml +++ b/app/src/main/res/menu/menu_playlist_actions.xml @@ -12,4 +12,7 @@ + \ 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 index e480d9191..c3e30e8b9 100644 --- a/app/src/main/res/menu/menu_playlist_detail.xml +++ b/app/src/main/res/menu/menu_playlist_detail.xml @@ -6,4 +6,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cbc3db5e4..2bd4d67d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -79,6 +79,7 @@ Playlist Playlists New playlist + Delete Search From 9c7e1d9fc2b509bcca86ef67eb994e4c4b33ba65 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 17 May 2023 11:12:26 -0600 Subject: [PATCH 56/88] image: differentiate different parent songs Differentiate parents based on their song composition. This way, it's less likely for an artist/genre/playlist image to get "stuck" after a library change. --- .../auxio/image/extractor/Components.kt | 21 +++++++------------ .../auxio/image/extractor/ExtractorModule.kt | 5 ++++- 2 files changed, 11 insertions(+), 15 deletions(-) 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 bd2f8f1a2..3f0ecfb38 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 @@ -35,20 +35,13 @@ import okio.source import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* -/** - * A [Keyer] implementation for [Music] data. - * - * @author Alexander Capehart (OxygenCobalt) - */ -class MusicKeyer : Keyer { - // TODO: Include hashcode of child songs for parents - 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() : Keyer { + override fun key(data: Song, options: Options) = + "${data.album.uid}${data.album.songs.hashCode()}" +} + +class ParentKeyer @Inject constructor() : Keyer { + override fun key(data: MusicParent, options: Options) = "${data.uid}${data.songs.hashCode()}" } /** 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 index 91adba89e..82ec32e07 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt @@ -35,6 +35,8 @@ class ExtractorModule { @Provides fun imageLoader( @ApplicationContext context: Context, + songKeyer: SongKeyer, + parentKeyer: ParentKeyer, songFactory: AlbumCoverFetcher.SongFactory, albumFactory: AlbumCoverFetcher.AlbumFactory, artistFactory: ArtistImageFetcher.Factory, @@ -44,7 +46,8 @@ class ExtractorModule { ImageLoader.Builder(context) .components { // Add fetchers for Music components to make them usable with ImageRequest - add(MusicKeyer()) + add(songKeyer) + add(parentKeyer) add(songFactory) add(albumFactory) add(artistFactory) From 6cfb50a10f0cc7649fc4abadca72cd52c0ce1bd5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 17 May 2023 11:25:30 -0600 Subject: [PATCH 57/88] detail: drop equality checks Most of the list creation operations are O(1), and the stateflow equality makes it not matter anyway. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 24 ------------------- .../auxio/music/device/DeviceLibrary.kt | 2 +- .../auxio/music/device/DeviceMusicImpl.kt | 3 +++ .../auxio/music/picker/AddToPlaylistDialog.kt | 1 - .../auxio/music/picker/NewPlaylistDialog.kt | 2 +- .../music/picker/PlaylistPickerViewModel.kt | 17 ++----------- .../auxio/music/FakeMusicRepository.kt | 4 ++++ 7 files changed, 11 insertions(+), 42 deletions(-) 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 dffde42ac..fcbbe4a33 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -46,8 +46,6 @@ import org.oxycblt.auxio.util.* * [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the * current item they are showing, sub-data to display, and configuration. * - * FIXME: Need to do direct item comparison in equality checks, or reset on navigation. - * * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel @@ -163,9 +161,7 @@ constructor( var playlistSongSort: Sort get() = musicSettings.playlistSongSort set(value) { - logD(value) musicSettings.playlistSongSort = value - logD(musicSettings.playlistSongSort) // Refresh the playlist list to reflect the new sort. currentPlaylist.value?.let { refreshPlaylistList(it, true) } } @@ -234,10 +230,6 @@ constructor( * @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 - } logD("Opening Song [uid: $uid]") _currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo) } @@ -249,10 +241,6 @@ constructor( * @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 - } logD("Opening Album [uid: $uid]") _currentAlbum.value = musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList) @@ -265,10 +253,6 @@ constructor( * @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 - } logD("Opening Artist [uid: $uid]") _currentArtist.value = musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList) @@ -281,10 +265,6 @@ constructor( * @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 - } logD("Opening Genre [uid: $uid]") _currentGenre.value = musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) @@ -297,10 +277,6 @@ constructor( * @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid. */ fun setPlaylistUid(uid: Music.UID) { - if (_currentPlaylist.value?.uid == uid) { - // Nothing to do. - return - } logD("Opening Playlist [uid: $uid]") _currentPlaylist.value = musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList) diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 44ac4e99d..120cd5875 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -170,7 +170,7 @@ private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings */ 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/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 1de1db4a8..ef17f395d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -34,6 +34,9 @@ import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.update +// TODO: Entirely rework music equality such that it's not completely UID-focused and actually +// takes metadata into account + /** * Library-backed implementation of [Song]. * diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt index eb81775c2..03cb0e597 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -82,7 +82,6 @@ class AddToPlaylistDialog : override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) { musicModel.addToPlaylist(pickerModel.currentPendingSongs.value ?: return, item.playlist) - pickerModel.confirmPlaylistAddition() requireContext().showToast(R.string.lng_playlist_added) findNavController().navigateUp() } 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 index 27d84fbec..d4b4d02ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt @@ -60,7 +60,7 @@ class NewPlaylistDialog : ViewBindingDialogFragment() } // TODO: Navigate to playlist if there are songs in it musicModel.createPlaylist(name, pendingPlaylist.songs) - pickerModel.confirmPlaylistCreation() + pickerModel.dropPendingAddition() requireContext().showToast(R.string.lng_playlist_created) } .setNegativeButton(R.string.lbl_cancel, null) 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 index 8fb9a9eb1..fe4b482c7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt @@ -105,10 +105,6 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M * @param songUids The [Music.UID]s of songs to be present in the playlist. */ fun setPendingPlaylist(context: Context, songUids: Array) { - if (currentPendingPlaylist.value?.songs?.map { it.uid } == songUids) { - // Nothing to do. - return - } val deviceLibrary = musicRepository.deviceLibrary ?: return val songs = songUids.mapNotNull(deviceLibrary::findSong) @@ -146,15 +142,6 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } } - /** Confirm the playlist creation process as completed. */ - fun confirmPlaylistCreation() { - // Confirm any playlist additions if needed, as the creation process may have been started - // by it and is still waiting on a result. - confirmPlaylistAddition() - _currentPendingPlaylist.value = null - _chosenName.value = ChosenName.Empty - } - /** * Update the current [Song]s that to show playlist add choices for. Will do nothing if already * equal. @@ -169,8 +156,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M refreshPlaylistChoices(songs) } - /** Mark the addition process as complete. */ - fun confirmPlaylistAddition() { + /** Drop any pending songs to add since a playlist has already been found for them. */ + fun dropPendingAddition() { _currentPendingSongs.value = null } diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt index a09aa1722..7ae9197b6 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt @@ -62,6 +62,10 @@ open class FakeMusicRepository : MusicRepository { throw NotImplementedError() } + override fun deletePlaylist(playlist: Playlist) { + throw NotImplementedError() + } + override fun addToPlaylist(songs: List, playlist: Playlist) { throw NotImplementedError() } From d0a68353a7b46214697dbd9e32e97fdc07792649 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 17 May 2023 12:08:31 -0600 Subject: [PATCH 58/88] music: simplify picker closing Simplify picker closing navigation by avoiding weird communication via state change and instead simply navigate twice from NewPlaylistDialog. This is probably really stupid. But so is the other way. --- .../org/oxycblt/auxio/detail/PlaylistDetailFragment.kt | 2 +- .../org/oxycblt/auxio/music/device/DeviceMusicImpl.kt | 1 + .../oxycblt/auxio/music/picker/AddToPlaylistDialog.kt | 3 ++- .../org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt | 9 ++++++++- app/src/main/res/layout/fragment_main.xml | 2 -- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index d1214f6b2..8c4cb4d2f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -127,7 +127,7 @@ class PlaylistDetailFragment : true } R.id.action_delete -> { - musicModel.deletePlaylist(currentPlaylist) + musicModel.createPlaylist() true } else -> false diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index ef17f395d..4de6f3c37 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -36,6 +36,7 @@ import org.oxycblt.auxio.util.update // TODO: Entirely rework music equality such that it's not completely UID-focused and actually // takes metadata into account +// TODO: Reduce need for raw objects to save some memory /** * Library-backed implementation of [Song]. diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt index 03cb0e597..e1058d1b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -22,6 +22,7 @@ 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 @@ -47,7 +48,7 @@ class AddToPlaylistDialog : ClickableListListener, NewPlaylistFooterAdapter.Listener { private val musicModel: MusicViewModel by activityViewModels() - private val pickerModel: PlaylistPickerViewModel 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() 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 index d4b4d02ea..6c14b6d04 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt @@ -23,6 +23,7 @@ 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 @@ -42,7 +43,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull @AndroidEntryPoint class NewPlaylistDialog : ViewBindingDialogFragment() { private val musicModel: MusicViewModel by activityViewModels() - private val pickerModel: PlaylistPickerViewModel 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() @@ -62,6 +63,12 @@ class NewPlaylistDialog : ViewBindingDialogFragment() musicModel.createPlaylist(name, pendingPlaylist.songs) pickerModel.dropPendingAddition() 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) } 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"> - - Date: Wed, 17 May 2023 15:45:09 -0600 Subject: [PATCH 59/88] music: extend equality impls Extend the music equals/hashCode implementations to take into account the raw music. This way, items that change in non-UID metadata are actually considered different at runtime while UIDs can still persist the data in a stable manner. --- .../auxio/detail/PlaylistDetailFragment.kt | 2 +- .../auxio/music/device/DeviceMusicImpl.kt | 49 ++++++++++++------- .../oxycblt/auxio/music/device/RawMusic.kt | 8 +-- .../auxio/music/picker/NewPlaylistDialog.kt | 1 - .../music/picker/PlaylistPickerViewModel.kt | 5 -- .../oxycblt/auxio/music/user/UserLibrary.kt | 5 -- 6 files changed, 36 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 8c4cb4d2f..d1214f6b2 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -127,7 +127,7 @@ class PlaylistDetailFragment : true } R.id.action_delete -> { - musicModel.createPlaylist() + musicModel.deletePlaylist(currentPlaylist) true } else -> false diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 4de6f3c37..c98049d68 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -34,10 +34,6 @@ import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.update -// TODO: Entirely rework music equality such that it's not completely UID-focused and actually -// takes metadata into account -// TODO: Reduce need for raw objects to save some memory - /** * Library-backed implementation of [Song]. * @@ -45,7 +41,7 @@ import org.oxycblt.auxio.util.update * @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) } @@ -89,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) @@ -248,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 @@ -341,9 +341,18 @@ class ArtistImpl( // Note: Append song contents to MusicParent equality so that artists with // the same UID but different songs are not equal. - override fun hashCode() = 31 * uid.hashCode() + songs.hashCode() + override fun hashCode(): Int { + var hashCode = uid.hashCode() + hashCode = 31 * hashCode + rawArtist.hashCode() + hashCode = 31 * hashCode + songs.hashCode() + return hashCode + } + override fun equals(other: Any?) = - 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 @@ -422,11 +431,15 @@ class GenreImpl( 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() diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index bf3bb5794..8c91f09f9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.music.metadata.* * * @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. @@ -95,7 +95,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. @@ -142,7 +142,7 @@ class RawAlbum( * * @author Alexander Capehart (OxygenCobalt) */ -class RawArtist( +data class RawArtist( /** @see Music.UID */ val musicBrainzId: UUID? = null, /** @see Music.name */ @@ -184,7 +184,7 @@ class RawArtist( * * @author Alexander Capehart (OxygenCobalt) */ -class RawGenre( +data class RawGenre( /** @see Music.name */ val name: String? = null ) { 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 index 6c14b6d04..f58ba4b3f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt @@ -61,7 +61,6 @@ class NewPlaylistDialog : ViewBindingDialogFragment() } // TODO: Navigate to playlist if there are songs in it musicModel.createPlaylist(name, pendingPlaylist.songs) - pickerModel.dropPendingAddition() requireContext().showToast(R.string.lng_playlist_created) findNavController().apply { navigateUp() 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 index fe4b482c7..c5508b2e0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt @@ -156,11 +156,6 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M refreshPlaylistChoices(songs) } - /** Drop any pending songs to add since a playlist has already been found for them. */ - fun dropPendingAddition() { - _currentPendingSongs.value = null - } - private fun refreshPlaylistChoices(songs: List) { val userLibrary = musicRepository.userLibrary ?: return _playlistChoices.value = 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 index f2df2d1a5..7ea2c05b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -112,11 +112,6 @@ private class UserLibraryImpl( override val playlists: List get() = playlistMap.values.toList() - init { - // TODO: Actually read playlists - createPlaylist("Playlist 1", deviceLibrary.songs.slice(58..200)) - } - override fun findPlaylist(uid: Music.UID) = playlistMap[uid] override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } From b2e899a211bf2deddd58650639eae63be8b9a650 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 17 May 2023 17:11:35 -0600 Subject: [PATCH 60/88] search: add playlist results Add playlist results to the search view. --- .../org/oxycblt/auxio/search/SearchAdapter.kt | 3 ++ .../org/oxycblt/auxio/search/SearchEngine.kt | 10 +++-- .../oxycblt/auxio/search/SearchViewModel.kt | 45 ++++++++++++------- app/src/main/res/menu/menu_search.xml | 4 ++ 4 files changed, 43 insertions(+), 19 deletions(-) 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..191529cca 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -43,6 +43,7 @@ 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 BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE else -> super.getItemViewType(position) } @@ -53,6 +54,7 @@ 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) BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) else -> error("Invalid item type $viewType") } @@ -64,6 +66,7 @@ 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) } } 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 fa3cf2dea..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,6 +26,7 @@ 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 @@ -33,8 +34,6 @@ import org.oxycblt.auxio.music.info.Name * Implements the fuzzy-ish searching algorithm used in the search view. * * @author Alexander Capehart - * - * TODO: Add playlists */ interface SearchEngine { /** @@ -53,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? ) } @@ -72,7 +73,8 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte }, 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. 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 68a0b3b7d..e34dcf800 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -34,6 +34,7 @@ import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* 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 @@ -73,7 +74,7 @@ constructor( } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { + if (changes.deviceLibrary || changes.userLibrary) { search(lastQuery) } } @@ -90,7 +91,8 @@ constructor( lastQuery = query val deviceLibrary = musicRepository.deviceLibrary - if (query.isNullOrEmpty() || deviceLibrary == null) { + val userLibrary = musicRepository.userLibrary + if (query.isNullOrEmpty() || deviceLibrary == null || userLibrary == null) { logD("Search query is not applicable.") _searchResults.value = listOf() return @@ -101,11 +103,16 @@ constructor( // Searching is time-consuming, so do it in the background. currentSearchJob = viewModelScope.launch { - _searchResults.value = searchImpl(deviceLibrary, query).also { yield() } + _searchResults.value = + searchImpl(deviceLibrary, userLibrary, query).also { yield() } } } - private suspend fun searchImpl(deviceLibrary: DeviceLibrary, query: String): List { + private suspend fun searchImpl( + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary, + query: String + ): List { val filterMode = searchSettings.searchFilterMode val items = @@ -115,33 +122,40 @@ constructor( deviceLibrary.songs, deviceLibrary.albums, deviceLibrary.artists, - deviceLibrary.genres) + deviceLibrary.genres, + userLibrary.playlists) } else { SearchEngine.Items( 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) + 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 -> + results.artists?.let { add(BasicHeader(R.string.lbl_artists)) - addAll(SORT.artists(artists)) + addAll(SORT.artists(it)) } - results.albums?.let { albums -> + results.albums?.let { add(BasicHeader(R.string.lbl_albums)) - addAll(SORT.albums(albums)) + addAll(SORT.albums(it)) } - results.genres?.let { genres -> + results.playlists?.let { + add(BasicHeader(R.string.lbl_playlists)) + addAll(SORT.playlists(it)) + } + results.genres?.let { add(BasicHeader(R.string.lbl_genres)) - addAll(SORT.genres(genres)) + addAll(SORT.genres(it)) } - results.songs?.let { songs -> + results.songs?.let { add(BasicHeader(R.string.lbl_songs)) - addAll(SORT.songs(songs)) + addAll(SORT.songs(it)) } } } @@ -158,7 +172,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_all // TODO: Handle + MusicMode.PLAYLISTS -> R.id.option_filter_playlists // Null maps to filtering nothing. null -> R.id.option_filter_all } @@ -175,6 +189,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/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" /> + From d1f9200bf9c0950b3ed85c53268f1d863633c833 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 17 May 2023 17:39:52 -0600 Subject: [PATCH 61/88] list: unwind choiceviewholder Unwind ChoiceViewHolder into specific impls. Was not re-usable enough w/playlists in order to be reasonable. --- .../auxio/detail/DetailAppBarLayout.kt | 3 - .../auxio/list/recycler/ViewHolders.kt | 52 ----------- .../auxio/music/picker/AddToPlaylistDialog.kt | 14 +-- .../picker/ArtistNavigationChoiceAdapter.kt | 89 +++++++++++++++++++ .../picker/NavigateToArtistDialog.kt | 29 ++---- .../picker/ArtistPlaybackChoiceAdapter.kt | 88 ++++++++++++++++++ .../picker/GenrePlaybackChoiceAdapter.kt | 88 ++++++++++++++++++ .../playback/picker/PlayFromArtistDialog.kt | 29 ++---- .../playback/picker/PlayFromGenreDialog.kt | 27 ++---- ...ic_picker.xml => dialog_music_choices.xml} | 3 +- app/src/main/res/navigation/nav_main.xml | 6 +- 11 files changed, 297 insertions(+), 131 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationChoiceAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackChoiceAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackChoiceAdapter.kt rename app/src/main/res/layout/{dialog_music_picker.xml => dialog_music_choices.xml} (76%) 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..ae1325daf 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 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 62fffeb54..7119a163c 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 @@ -24,10 +24,8 @@ 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.ItemPickerChoiceBinding import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.list.BasicHeader -import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SimpleDiffCallback @@ -352,53 +350,3 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB } } } - -/** - * A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [T] item, for use - * in choice dialogs. Use [from] to create an instance. - * - * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Unwind this into specific impls - */ -class ChoiceViewHolder -private constructor(private val binding: ItemPickerChoiceBinding) : - DialogRecyclerView.ViewHolder(binding.root) { - /** - * Bind new data to this instance. - * - * @param music The new [T] to bind. - * @param listener A [ClickableListListener] to bind interactions to. - */ - fun bind(music: T, listener: ClickableListListener) { - listener.bind(music, this) - // ImageGroup is not generic, so we must downcast to specific types for now. - when (music) { - is Song -> binding.pickerImage.bind(music) - is Album -> binding.pickerImage.bind(music) - is Artist -> binding.pickerImage.bind(music) - is Genre -> binding.pickerImage.bind(music) - is Playlist -> binding.pickerImage.bind(music) - } - binding.pickerName.text = music.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) = - ChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) - - /** Get a comparator that can be used with DiffUtil. */ - fun diffCallback() = - object : SimpleDiffCallback() { - override fun areContentsTheSame(oldItem: T, newItem: T) = - oldItem.name == newItem.name - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt index e1058d1b5..dc3ceb556 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -29,7 +29,7 @@ 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.DialogMusicPickerBinding +import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song @@ -44,7 +44,7 @@ import org.oxycblt.auxio.util.showToast */ @AndroidEntryPoint class AddToPlaylistDialog : - ViewBindingDialogFragment(), + ViewBindingDialogFragment(), ClickableListListener, NewPlaylistFooterAdapter.Listener { private val musicModel: MusicViewModel by activityViewModels() @@ -60,12 +60,12 @@ class AddToPlaylistDialog : } override fun onCreateBinding(inflater: LayoutInflater) = - DialogMusicPickerBinding.inflate(inflater) + DialogMusicChoicesBinding.inflate(inflater) - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: DialogMusicChoicesBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) - binding.pickerChoiceRecycler.apply { + binding.choiceRecycler.apply { itemAnimator = null adapter = ConcatAdapter(choiceAdapter, footerAdapter) } @@ -76,9 +76,9 @@ class AddToPlaylistDialog : collectImmediately(pickerModel.playlistChoices, ::updatePlaylistChoices) } - override fun onDestroyBinding(binding: DialogMusicPickerBinding) { + override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { super.onDestroyBinding(binding) - binding.pickerChoiceRecycler.adapter = null + binding.choiceRecycler.adapter = null } override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) { 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 index 1d3f83543..a8614af77 100644 --- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.navigation.picker import android.os.Bundle import android.view.LayoutInflater -import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -29,11 +28,9 @@ 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.FlexibleListAdapter import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.list.recycler.ChoiceViewHolder import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment @@ -46,25 +43,25 @@ import org.oxycblt.auxio.util.collectImmediately */ @AndroidEntryPoint class NavigateToArtistDialog : - ViewBindingDialogFragment(), ClickableListListener { + 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 = ArtistChoiceAdapter(this) + 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) = - DialogMusicPickerBinding.inflate(inflater) + DialogMusicChoicesBinding.inflate(inflater) - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: DialogMusicChoicesBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) - binding.pickerChoiceRecycler.apply { + binding.choiceRecycler.apply { itemAnimator = null adapter = choiceAdapter } @@ -79,7 +76,7 @@ class NavigateToArtistDialog : } } - override fun onDestroyBinding(binding: DialogMusicPickerBinding) { + override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { super.onDestroyBinding(binding) choiceAdapter } @@ -89,16 +86,4 @@ class NavigateToArtistDialog : navigationModel.exploreNavigateTo(item) findNavController().navigateUp() } - - private class ArtistChoiceAdapter(private val listener: ClickableListListener) : - FlexibleListAdapter>(ChoiceViewHolder.diffCallback()) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): ChoiceViewHolder = ChoiceViewHolder.from(parent) - - override fun onBindViewHolder(holder: ChoiceViewHolder, position: Int) { - holder.bind(getItem(position), listener) - } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackChoiceAdapter.kt new file mode 100644 index 000000000..be8bed183 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackChoiceAdapter.kt @@ -0,0 +1,88 @@ +/* + * 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 + * 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.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] playback choices, for use with + * [PlayFromArtistDialog]. + * + * @param listener A [ClickableListListener] to bind interactions to. + */ +class ArtistPlaybackChoiceAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter( + ArtistPlaybackChoiceViewHolder.DIFF_CALLBACK) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ArtistPlaybackChoiceViewHolder.from(parent) + + 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 [ArtistPlaybackChoiceAdapter]. Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class ArtistPlaybackChoiceViewHolder +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) = + 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/playback/picker/GenrePlaybackChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackChoiceAdapter.kt new file mode 100644 index 000000000..f5fdbf970 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackChoiceAdapter.kt @@ -0,0 +1,88 @@ +/* + * 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 + * 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.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.Genre +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.inflater + +/** + * A [FlexibleListAdapter] that displays a list of [Genre] playback choices, for use with + * [PlayFromGenreDialog]. + * + * @param listener A [ClickableListListener] to bind interactions to. + */ +class GenrePlaybackChoiceAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter( + GenrePlaybackChoiceViewHolder.DIFF_CALLBACK) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + GenrePlaybackChoiceViewHolder.from(parent) + + 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 [GenrePlaybackChoiceAdapter]. Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class GenrePlaybackChoiceViewHolder +private constructor(private val binding: ItemPickerChoiceBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + /** + * Bind new data to this instance. + * + * @param artist The new [Genre] to bind. + * @param listener A [ClickableListListener] to bind interactions to. + */ + 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. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: View) = + 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 index d0907c39f..0d477bd8d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.playback.picker import android.os.Bundle import android.view.LayoutInflater -import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -29,11 +28,9 @@ 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.FlexibleListAdapter import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.list.recycler.ChoiceViewHolder import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment @@ -47,25 +44,25 @@ import org.oxycblt.auxio.util.unlikelyToBeNull */ @AndroidEntryPoint class PlayFromArtistDialog : - ViewBindingDialogFragment(), ClickableListListener { + 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 = ArtistChoiceAdapter(this) + 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) = - DialogMusicPickerBinding.inflate(inflater) + DialogMusicChoicesBinding.inflate(inflater) - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: DialogMusicChoicesBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) - binding.pickerChoiceRecycler.apply { + binding.choiceRecycler.apply { itemAnimator = null adapter = choiceAdapter } @@ -80,7 +77,7 @@ class PlayFromArtistDialog : } } - override fun onDestroyBinding(binding: DialogMusicPickerBinding) { + override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { super.onDestroyBinding(binding) choiceAdapter } @@ -91,16 +88,4 @@ class PlayFromArtistDialog : playbackModel.playFromArtist(song, item) findNavController().navigateUp() } - - private class ArtistChoiceAdapter(private val listener: ClickableListListener) : - FlexibleListAdapter>(ChoiceViewHolder.diffCallback()) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): ChoiceViewHolder = ChoiceViewHolder.from(parent) - - override fun onBindViewHolder(holder: ChoiceViewHolder, position: Int) { - holder.bind(getItem(position), listener) - } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt index 21c81aa25..0b8914dc2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.playback.picker import android.os.Bundle import android.view.LayoutInflater -import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -29,11 +28,9 @@ 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.FlexibleListAdapter import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.list.recycler.ChoiceViewHolder import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment @@ -47,25 +44,25 @@ import org.oxycblt.auxio.util.unlikelyToBeNull */ @AndroidEntryPoint class PlayFromGenreDialog : - ViewBindingDialogFragment(), ClickableListListener { + 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: PlayFromGenreDialogArgs by navArgs() - private val choiceAdapter = GenreChoiceAdapter(this) + 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 onCreateBinding(inflater: LayoutInflater) = - DialogMusicPickerBinding.inflate(inflater) + DialogMusicChoicesBinding.inflate(inflater) - override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: DialogMusicChoicesBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) - binding.pickerChoiceRecycler.apply { + binding.choiceRecycler.apply { itemAnimator = null adapter = choiceAdapter } @@ -80,7 +77,7 @@ class PlayFromGenreDialog : } } - override fun onDestroyBinding(binding: DialogMusicPickerBinding) { + override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { super.onDestroyBinding(binding) choiceAdapter } @@ -91,14 +88,4 @@ class PlayFromGenreDialog : playbackModel.playFromGenre(song, item) findNavController().navigateUp() } - - private class GenreChoiceAdapter(private val listener: ClickableListListener) : - FlexibleListAdapter>(ChoiceViewHolder.diffCallback()) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChoiceViewHolder = - ChoiceViewHolder.from(parent) - - override fun onBindViewHolder(holder: ChoiceViewHolder, position: Int) { - holder.bind(getItem(position), listener) - } - } } diff --git a/app/src/main/res/layout/dialog_music_picker.xml b/app/src/main/res/layout/dialog_music_choices.xml similarity index 76% rename from app/src/main/res/layout/dialog_music_picker.xml rename to app/src/main/res/layout/dialog_music_choices.xml index 8c1d82ee3..64c36ada8 100644 --- a/app/src/main/res/layout/dialog_music_picker.xml +++ b/app/src/main/res/layout/dialog_music_choices.xml @@ -1,9 +1,8 @@ - + tools:layout="@layout/dialog_music_choices"> @@ -81,7 +81,7 @@ android:id="@+id/play_from_artist_dialog" android:name="org.oxycblt.auxio.playback.picker.PlayFromArtistDialog" android:label="play_from_artist_dialog" - tools:layout="@layout/dialog_music_picker"> + tools:layout="@layout/dialog_music_choices"> @@ -91,7 +91,7 @@ android:id="@+id/play_from_genre_dialog" android:name="org.oxycblt.auxio.playback.picker.PlayFromGenreDialog" android:label="play_from_genre_dialog" - tools:layout="@layout/dialog_music_picker"> + tools:layout="@layout/dialog_music_choices"> From 97e144058abeb4f07958798947b63983f79a5701 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 17 May 2023 18:51:38 -0600 Subject: [PATCH 62/88] music: add playlist deletion dialog Add a dialog that allows the user to confirm playlist deletion, instead of it happening immediately. --- .../java/org/oxycblt/auxio/MainFragment.kt | 9 ++ .../auxio/detail/AlbumDetailFragment.kt | 2 +- .../auxio/detail/ArtistDetailFragment.kt | 2 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 24 +++--- .../auxio/detail/GenreDetailFragment.kt | 2 +- .../oxycblt/auxio/detail/SongDetailDialog.kt | 2 +- .../org/oxycblt/auxio/music/MusicViewModel.kt | 25 ++++-- .../auxio/music/picker/AddToPlaylistDialog.kt | 8 +- .../music/picker/DeletePlaylistDialog.kt | 84 +++++++++++++++++++ .../music/picker/PlaylistPickerViewModel.kt | 37 +++++--- .../res/layout/dialog_delete_playlist.xml | 11 +++ app/src/main/res/navigation/nav_main.xml | 13 +++ app/src/main/res/values/strings.xml | 2 + 13 files changed, 180 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt create mode 100644 app/src/main/res/layout/dialog_delete_playlist.xml diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 4877ee253..106ba2885 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -41,6 +41,7 @@ import org.oxycblt.auxio.databinding.FragmentMainBinding 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 @@ -136,6 +137,7 @@ class MainFragment : collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) + collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist) collectImmediately(playbackModel.song, ::updateSong) collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker) collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker) @@ -322,6 +324,13 @@ class MainFragment : } } + private fun handleDeletePlaylist(playlist: Playlist?) { + if (playlist != null) { + findNavController() + .navigateSafe(MainFragmentDirections.actionDeletePlaylist(playlist.uid)) + musicModel.playlistToDelete.consume() + } + } private fun handlePlaybackArtistPicker(song: Song?) { if (song != null) { navModel.mainNavigateTo( 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 3f08beaab..42d6bb341 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -99,7 +99,7 @@ class AlbumDetailFragment : // -- 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( 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 046c52f29..4578f664d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -98,7 +98,7 @@ class ArtistDetailFragment : // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. - detailModel.setArtistUid(args.artistUid) + detailModel.setArtist(args.artistUid) collectImmediately(detailModel.currentArtist, ::updateArtist) collectImmediately(detailModel.artistList, ::updateList) collectImmediately( 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 fcbbe4a33..d2e87f6ed 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -224,47 +224,47 @@ constructor( } /** - * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and - * [songAudioProperties] 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) { + fun setSong(uid: Music.UID) { logD("Opening Song [uid: $uid]") _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) { + fun setAlbum(uid: Music.UID) { logD("Opening Album [uid: $uid]") _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) { + fun setArtist(uid: Music.UID) { logD("Opening Artist [uid: $uid]") _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) { + fun setGenre(uid: Music.UID) { logD("Opening Genre [uid: $uid]") _currentGenre.value = musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) 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 eb08d08a1..2028c3610 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -91,7 +91,7 @@ class GenreDetailFragment : // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. - detailModel.setGenreUid(args.genreUid) + detailModel.setGenre(args.genreUid) collectImmediately(detailModel.currentGenre, ::updatePlaylist) collectImmediately(detailModel.genreList, ::updateList) collectImmediately( 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 615f256e4..5ba78ea8f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -67,7 +67,7 @@ class SongDetailDialog : ViewBindingDialogFragment() { super.onBindingCreated(binding, savedInstanceState) binding.detailProperties.adapter = detailAdapter // DetailViewModel handles most initialization from the navigation argument. - detailModel.setSongUid(args.songUid) + detailModel.setSong(args.songUid) collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong) } 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 df53b581d..75527b6d8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -48,13 +48,18 @@ constructor( val statistics: StateFlow get() = _statistics - private val _newPlaylistSongs = MutableEvent?>() + private val _newPlaylistSongs = MutableEvent>() /** Flag for opening a dialog to create a playlist of the given [Song]s. */ - val newPlaylistSongs: Event?> = _newPlaylistSongs + val newPlaylistSongs: Event> = _newPlaylistSongs - private val _songsToAdd = MutableEvent?>() + private val _songsToAdd = MutableEvent>() /** Flag for opening a dialog to add the given [Song]s to a playlist. */ - val songsToAdd: Event?> = _songsToAdd + val songsToAdd: Event> = _songsToAdd + + private val _playlistToDelete = MutableEvent() + /** Flag for opening a dialog to confirm deletion of the given [Playlist]. */ + val playlistToDelete: Event + get() = _playlistToDelete init { musicRepository.addUpdateListener(this) @@ -110,11 +115,15 @@ constructor( * Delete a [Playlist]. * * @param playlist The playlist to delete. - * - * TODO: Prompt the user before deleting. + * @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) { - musicRepository.deletePlaylist(playlist) + fun deletePlaylist(playlist: Playlist, rude: Boolean = false) { + if (rude) { + musicRepository.deletePlaylist(playlist) + } else { + _playlistToDelete.put(playlist) + } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt index dc3ceb556..66660bb6d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -71,8 +71,8 @@ class AddToPlaylistDialog : } // --- VIEWMODEL SETUP --- - pickerModel.setPendingSongs(args.songUids) - collectImmediately(pickerModel.currentPendingSongs, ::updatePendingSongs) + pickerModel.setSongsToAdd(args.songUids) + collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs) collectImmediately(pickerModel.playlistChoices, ::updatePlaylistChoices) } @@ -82,13 +82,13 @@ class AddToPlaylistDialog : } override fun onClick(item: PlaylistChoice, viewHolder: RecyclerView.ViewHolder) { - musicModel.addToPlaylist(pickerModel.currentPendingSongs.value ?: return, item.playlist) + musicModel.addToPlaylist(pickerModel.currentSongsToAdd.value ?: return, item.playlist) requireContext().showToast(R.string.lng_playlist_added) findNavController().navigateUp() } override fun onNewPlaylist() { - musicModel.createPlaylist(songs = pickerModel.currentPendingSongs.value ?: return) + musicModel.createPlaylist(songs = pickerModel.currentSongsToAdd.value ?: return) } private fun updatePendingSongs(songs: List?) { 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..155846606 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt @@ -0,0 +1,84 @@ +/* + * 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.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) + } + .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/PlaylistPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt index c5508b2e0..750886f90 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt @@ -48,14 +48,18 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M val chosenName: StateFlow get() = _chosenName - private val _currentPendingSongs = MutableStateFlow?>(null) - val currentPendingSongs: StateFlow?> - get() = _currentPendingSongs + private val _currentSongsToAdd = MutableStateFlow?>(null) + val currentSongsToAdd: StateFlow?> + get() = _currentSongsToAdd private val _playlistChoices = MutableStateFlow>(listOf()) val playlistChoices: StateFlow> get() = _playlistChoices + private val _currentPlaylistToDelete = MutableStateFlow(null) + val currentPlaylistToDelete: StateFlow + get() = _currentPlaylistToDelete + init { musicRepository.addUpdateListener(this) } @@ -70,8 +74,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M pendingPlaylist.preferredName, pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }) } - _currentPendingSongs.value = - _currentPendingSongs.value?.let { pendingSongs -> + _currentSongsToAdd.value = + _currentSongsToAdd.value?.let { pendingSongs -> pendingSongs .mapNotNull { deviceLibrary.findSong(it.uid) } .ifEmpty { null } @@ -88,7 +92,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M // Nothing to do. } } - refreshChoicesWith = refreshChoicesWith ?: _currentPendingSongs.value + refreshChoicesWith = refreshChoicesWith ?: _currentSongsToAdd.value } refreshChoicesWith?.let(::refreshPlaylistChoices) @@ -99,7 +103,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } /** - * Update the current [PendingPlaylist]. Will do nothing if already equal. + * 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. @@ -121,7 +125,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } /** - * Update the current [ChosenName] based on new user input. + * Update the current [chosenName] based on new user input. * * @param name The new user-inputted name, or null if not present. */ @@ -143,16 +147,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } /** - * Update the current [Song]s that to show playlist add choices for. Will do nothing if already - * equal. + * 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 setPendingSongs(songUids: Array) { - if (currentPendingSongs.value?.map { it.uid } == songUids) return + fun setSongsToAdd(songUids: Array) { val deviceLibrary = musicRepository.deviceLibrary ?: return val songs = songUids.mapNotNull(deviceLibrary::findSong) - _currentPendingSongs.value = songs + _currentSongsToAdd.value = songs refreshPlaylistChoices(songs) } @@ -164,6 +166,15 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M PlaylistChoice(it, songs.all(songSet::contains)) } } + + /** + * 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) + } } /** 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/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 511e35393..a11e936ac 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -23,6 +23,9 @@ + @@ -67,6 +70,16 @@ app:destination="@id/new_playlist_dialog" /> + + + + Playlists New playlist Delete + Delete playlist? Search @@ -395,6 +396,7 @@ %d Hz Loading your music library… (%1$d/%2$d) + Delete %s? This cannot be undone. Songs loaded: %d Albums loaded: %d From ded7956319b189ef28313292188116a0cfc107f1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 17 May 2023 19:06:53 -0600 Subject: [PATCH 63/88] build: update media3 to 1.0.2 --- CHANGELOG.md | 3 +++ .../main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt | 2 -- .../org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt | 1 + media | 2 +- settings.gradle | 1 + 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbebcf85b..c9fd1f7d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ be parsed as images - 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") +#### Dev/Meta +- Switched to androidx media3 (New Home of ExoPlayer) for backing player components + ## 3.0.5 #### What's Fixed diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 120cd5875..af42d85ab 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -210,8 +210,6 @@ private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings albums: List, settings: MusicSettings ): List { - // TODO: Debug an issue with my current library config that results in two duplicate - // artists. // Add every raw artist credited to each Song/Album to the grouping. This way, // different multi-artist combinations are not treated as different artists. val musicByArtist = mutableMapOf>() 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 index 155846606..cada8ed00 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt @@ -36,6 +36,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull /** * A [ViewBindingDialogFragment] that asks the user to confirm the deletion of a [Playlist]. + * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint diff --git a/media b/media index 4ab06ffd6..8712967a7 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 4ab06ffd6039c038f2995f1a06bafed28bdd9be4 +Subproject commit 8712967a789192d60d2207451cd5ed2b3191999e diff --git a/settings.gradle b/settings.gradle index 4595c06e6..c8be53d93 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,5 @@ include ':app' rootProject.name = "Auxio" gradle.ext.androidxMediaModulePrefix = 'media-' +gradle.ext.androidxMediaProjectName = 'media-' apply from: file("media/core_settings.gradle") \ No newline at end of file From 08d36df905965a5051d0437e2ecaae71bb267966 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 18 May 2023 14:52:22 -0600 Subject: [PATCH 64/88] list: rework item arragement Fix two issues with the ways items are laid out: 1. Remove the automatic span size lookup. Now that ConcatAdpater is used, this basically becomes impossible to really leverage. 2. Use a divider item instead of a divider item decoration. The latter is too buggy in many contexts, like the search view. Resolves #426 Resolves #444 --- CHANGELOG.md | 2 + .../java/org/oxycblt/auxio/IntegerTable.kt | 12 ++-- .../auxio/detail/AlbumDetailFragment.kt | 16 ++++- .../auxio/detail/ArtistDetailFragment.kt | 15 +++- .../oxycblt/auxio/detail/DetailViewModel.kt | 26 +++++-- .../auxio/detail/GenreDetailFragment.kt | 15 +++- .../auxio/detail/PlaylistDetailFragment.kt | 15 +++- .../detail/list/AlbumDetailListAdapter.kt | 9 --- .../detail/list/ArtistDetailListAdapter.kt | 8 --- .../auxio/detail/list/DetailListAdapter.kt | 15 ++-- .../detail/list/GenreDetailListAdapter.kt | 8 --- .../detail/list/PlaylistDetailListAdapter.kt | 8 --- .../main/java/org/oxycblt/auxio/list/Data.kt | 2 + .../auxio/list/recycler/AuxioRecyclerView.kt | 35 ---------- .../list/recycler/HeaderItemDecoration.kt | 70 ------------------- .../auxio/list/recycler/ViewHolders.kt | 42 +++++++++-- .../org/oxycblt/auxio/search/SearchAdapter.kt | 11 +-- .../oxycblt/auxio/search/SearchFragment.kt | 11 ++- .../oxycblt/auxio/search/SearchViewModel.kt | 32 +++++++-- .../org/oxycblt/auxio/util/FrameworkUtil.kt | 15 ++++ app/src/main/res/layout/item_divider.xml | 4 ++ 21 files changed, 193 insertions(+), 178 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt create mode 100644 app/src/main/res/layout/item_divider.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index c9fd1f7d1..609c11d7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ 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 #### Dev/Meta - Switched to androidx media3 (New Home of ExoPlayer) for backing player components diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 3e9a639c3..93b8b239a 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -37,16 +37,18 @@ object IntegerTable { const val VIEW_TYPE_PLAYLIST = 0xA004 /** BasicHeaderViewHolder */ const val VIEW_TYPE_BASIC_HEADER = 0xA005 + /** DividerViewHolder */ + const val VIEW_TYPE_DIVIDER = 0xA006 /** SortHeaderViewHolder */ - const val VIEW_TYPE_SORT_HEADER = 0xA006 + 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 = 0xA008 + const val VIEW_TYPE_ARTIST_ALBUM = 0xA009 /** ArtistSongViewHolder */ - const val VIEW_TYPE_ARTIST_SONG = 0xA009 + const val VIEW_TYPE_ARTIST_SONG = 0xA00A /** DiscHeaderViewHolder */ - const val VIEW_TYPE_DISC_HEADER = 0xA00A + const val VIEW_TYPE_DISC_HEADER = 0xA00B /** "Music playback" notification code */ const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0 /** "Music loading" notification code */ 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 42d6bb341..b168f1afe 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 @@ -45,6 +48,7 @@ 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.util.* @@ -95,7 +99,17 @@ class AlbumDetailFragment : setOnMenuItemClickListener(this@AlbumDetailFragment) } - binding.detailRecycler.adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter) + binding.detailRecycler.apply { + adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter) + (layoutManager as GridLayoutManager).setFullWidthLookup { + if (it != 0) { + val item = detailModel.albumList.value[it - 1] + item is Divider || item is Header || item is Disc + } else { + true + } + } + } // -- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. 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 4578f664d..20b055183 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 @@ -94,7 +97,17 @@ class ArtistDetailFragment : setOnMenuItemClickListener(this@ArtistDetailFragment) } - binding.detailRecycler.adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter) + binding.detailRecycler.apply { + adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter) + (layoutManager as GridLayoutManager).setFullWidthLookup { + if (it != 0) { + val item = detailModel.artistList.value[it - 1] + item is Divider || item is Header + } else { + true + } + } + } // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. 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 d2e87f6ed..127c84468 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.yield import org.oxycblt.auxio.R 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 @@ -297,7 +298,9 @@ constructor( private fun refreshAlbumList(album: Album, replace: Boolean = false) { 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 @@ -355,7 +358,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) } @@ -363,7 +368,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) @@ -379,9 +386,14 @@ constructor( 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 @@ -400,7 +412,9 @@ constructor( val list = mutableListOf() if (playlist.songs.isNotEmpty()) { - list.add(SortHeader(R.string.lbl_songs)) + val header = SortHeader(R.string.lbl_songs) + list.add(Divider(header)) + list.add(header) if (replace) { instructions = UpdateInstructions.Replace(list.size) } 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 2028c3610..2729267f7 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,6 +35,8 @@ 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 @@ -87,7 +90,17 @@ class GenreDetailFragment : setOnMenuItemClickListener(this@GenreDetailFragment) } - binding.detailRecycler.adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter) + binding.detailRecycler.apply { + adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter) + (layoutManager as GridLayoutManager).setFullWidthLookup { + if (it != 0) { + val item = detailModel.genreList.value[it - 1] + item is Divider || item is Header + } else { + true + } + } + } // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index d1214f6b2..d1b7da0c1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.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.DetailHeaderAdapter import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter +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 @@ -87,7 +90,17 @@ class PlaylistDetailFragment : setOnMenuItemClickListener(this@PlaylistDetailFragment) } - binding.detailRecycler.adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) + binding.detailRecycler.apply { + adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) + (layoutManager as GridLayoutManager).setFullWidthLookup { + if (it != 0) { + val item = detailModel.playlistList.value[it - 1] + item is Divider || item is Header + } else { + true + } + } + } // --- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. 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 b3d01e970..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 @@ -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 = 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 f27a11100..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 = 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..cd23751be 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 -> 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 e4a33c80b..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 @@ -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 index a6c695ac2..5a33e511f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -56,14 +56,6 @@ class PlaylistDetailListAdapter(private val listener: Listener) : } } - override fun isItemFullWidth(position: Int): Boolean { - if (super.isItemFullWidth(position)) { - return true - } - // Playlist headers should be full-width in all configurations - return getItem(position) is Playlist - } - companion object { val DIFF_CALLBACK = object : SimpleDiffCallback() { 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..27f059602 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,5 @@ interface Header : Item { * @author Alexander Capehart (OxygenCobalt) */ data class BasicHeader(@StringRes override val titleRes: Int) : Header + +data class Divider(val anchor: Header?) : Item 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 b535feba2..eb7c820a4 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 * * FIXME: Broken span configuration @@ -49,7 +47,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) { @@ -67,36 +64,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/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index 7119a163c..1b575978f 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 @@ -22,10 +22,12 @@ import android.view.View import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.ItemDividerBinding 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 @@ -246,7 +248,7 @@ 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 = + override fun areContentsTheSame(oldItem: Genre, newItem: Genre) = oldItem.name == newItem.name && oldItem.artists.size == newItem.artists.size && oldItem.songs.size == newItem.songs.size @@ -304,7 +306,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = object : SimpleDiffCallback() { - override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist): Boolean = + override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist) = oldItem.name == newItem.name && oldItem.songs.size == newItem.songs.size } } @@ -343,10 +345,38 @@ 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(private val binding: ItemDividerBinding) : + RecyclerView.ViewHolder(binding.root) { + + 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(ItemDividerBinding.inflate(parent.context.inflater)) + + /** 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/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index 191529cca..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)) { @@ -44,6 +43,7 @@ class SearchAdapter(private val listener: SelectableListListener) : 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) } @@ -55,6 +55,7 @@ class SearchAdapter(private val listener: SelectableListListener) : 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") } @@ -71,8 +72,6 @@ class SearchAdapter(private val listener: SelectableListListener) : } } - override fun isItemFullWidth(position: Int) = getItem(position) is BasicHeader - private companion object { /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = @@ -87,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/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 7846710b9..b0a0feb06 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -29,10 +29,13 @@ 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 @@ -104,7 +107,13 @@ class SearchFragment : ListFragment() { } } - binding.searchRecycler.adapter = searchAdapter + binding.searchRecycler.apply { + adapter = searchAdapter + (layoutManager as GridLayoutManager).setFullWidthLookup { + val item = searchModel.searchResults.value[it] + item is Divider || item is Header + } + } // --- VIEWMODEL SETUP --- 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 e34dcf800..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,6 +30,7 @@ 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.* @@ -138,23 +139,44 @@ constructor( return buildList { results.artists?.let { - add(BasicHeader(R.string.lbl_artists)) + val header = BasicHeader(R.string.lbl_artists) + add(header) addAll(SORT.artists(it)) } results.albums?.let { - add(BasicHeader(R.string.lbl_albums)) + val header = BasicHeader(R.string.lbl_albums) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) addAll(SORT.albums(it)) } results.playlists?.let { - add(BasicHeader(R.string.lbl_playlists)) + val header = BasicHeader(R.string.lbl_playlists) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) addAll(SORT.playlists(it)) } results.genres?.let { - add(BasicHeader(R.string.lbl_genres)) + val header = BasicHeader(R.string.lbl_genres) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) addAll(SORT.genres(it)) } results.songs?.let { - add(BasicHeader(R.string.lbl_songs)) + val header = BasicHeader(R.string.lbl_songs) + if (isNotEmpty()) { + add(Divider(header)) + } + + add(header) addAll(SORT.songs(it)) } } 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 14cd2a20e..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,6 +31,7 @@ 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 @@ -105,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. diff --git a/app/src/main/res/layout/item_divider.xml b/app/src/main/res/layout/item_divider.xml new file mode 100644 index 000000000..4767eae41 --- /dev/null +++ b/app/src/main/res/layout/item_divider.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file From 3a5e1a51116179b9f3cc94369ee9af0071f140be Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 18 May 2023 17:09:26 -0600 Subject: [PATCH 65/88] music: add playlist renaming Add the flow for renaming a playlist. --- .../java/org/oxycblt/auxio/MainFragment.kt | 22 +++-- .../auxio/detail/PlaylistDetailFragment.kt | 4 + .../main/java/org/oxycblt/auxio/list/Data.kt | 6 ++ .../org/oxycblt/auxio/list/ListFragment.kt | 3 + .../auxio/list/recycler/AuxioRecyclerView.kt | 2 - .../auxio/list/recycler/ViewHolders.kt | 9 +- .../java/org/oxycblt/auxio/music/Music.kt | 10 ++ .../oxycblt/auxio/music/MusicRepository.kt | 17 ++++ .../org/oxycblt/auxio/music/MusicViewModel.kt | 25 ++++- .../auxio/music/picker/AddToPlaylistDialog.kt | 2 +- .../music/picker/DeletePlaylistDialog.kt | 2 + .../auxio/music/picker/NewPlaylistDialog.kt | 2 +- .../music/picker/PlaylistPickerViewModel.kt | 53 ++++++---- .../music/picker/RenamePlaylistDialog.kt | 98 +++++++++++++++++++ .../oxycblt/auxio/music/user/PlaylistImpl.kt | 21 +++- .../oxycblt/auxio/music/user/UserLibrary.kt | 15 +++ .../main/res/menu/menu_playlist_actions.xml | 3 + .../main/res/menu/menu_playlist_detail.xml | 3 + app/src/main/res/navigation/nav_main.xml | 33 +++++-- app/src/main/res/values/strings.xml | 4 + 20 files changed, 285 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 106ba2885..90ada8b9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -136,8 +136,9 @@ class MainFragment : collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation) collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker) collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist) - collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist) + 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) @@ -315,12 +316,11 @@ class MainFragment : } } - private fun handleAddToPlaylist(songs: List?) { - if (songs != null) { + private fun handleRenamePlaylist(playlist: Playlist?) { + if (playlist != null) { findNavController() - .navigateSafe( - MainFragmentDirections.actionAddToPlaylist(songs.map { it.uid }.toTypedArray())) - musicModel.songsToAdd.consume() + .navigateSafe(MainFragmentDirections.actionRenamePlaylist(playlist.uid)) + musicModel.playlistToRename.consume() } } @@ -331,6 +331,16 @@ class MainFragment : 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( diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index d1b7da0c1..9b7d78b5c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -139,6 +139,10 @@ class PlaylistDetailFragment : requireContext().showToast(R.string.lng_queue_added) true } + R.id.action_rename -> { + musicModel.renamePlaylist(currentPlaylist) + true + } R.id.action_delete -> { musicModel.deletePlaylist(currentPlaylist) true 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 27f059602..e41dd4149 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Data.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Data.kt @@ -41,4 +41,10 @@ interface Header : Item { */ 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 f2e050e40..49655d01b 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -255,6 +255,9 @@ abstract class ListFragment : playbackModel.addToQueue(playlist) requireContext().showToast(R.string.lng_queue_added) } + R.id.action_rename -> { + musicModel.renamePlaylist(playlist) + } R.id.action_delete -> { musicModel.deletePlaylist(playlist) } 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 eb7c820a4..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 @@ -31,8 +31,6 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * - Automatic edge-to-edge support * - Automatic [setHasFixedSize] setup * - * FIXME: Broken span configuration - * * @author Alexander Capehart (OxygenCobalt) */ open class AuxioRecyclerView 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 1b575978f..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,9 +20,9 @@ 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.ItemDividerBinding import org.oxycblt.auxio.databinding.ItemHeaderBinding import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding @@ -356,8 +356,8 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB * * @author Alexander Capehart (OxygenCobalt) */ -class DividerViewHolder private constructor(private val binding: ItemDividerBinding) : - RecyclerView.ViewHolder(binding.root) { +class DividerViewHolder private constructor(divider: MaterialDivider) : + RecyclerView.ViewHolder(divider) { companion object { /** Unique ID for this ViewHolder type. */ @@ -369,8 +369,7 @@ class DividerViewHolder private constructor(private val binding: ItemDividerBind * @param parent The parent to inflate this instance from. * @return A new instance. */ - fun from(parent: View) = - DividerViewHolder(ItemDividerBinding.inflate(parent.context.inflater)) + fun from(parent: View) = DividerViewHolder(MaterialDivider(parent.context)) /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = 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 37cbf83bc..bcf2fb53e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -118,6 +118,16 @@ sealed interface Music : Item { } 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. 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 99e93ffb0..91ad069fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -118,6 +118,14 @@ interface MusicRepository { */ fun createPlaylist(name: String, songs: List) + /** + * Rename a [Playlist]. + * + * @param playlist The [Playlist] to rename. + * @param name The name of the new [Playlist]. + */ + fun renamePlaylist(playlist: Playlist, name: String) + /** * Delete a [Playlist]. * @@ -269,6 +277,15 @@ constructor( } } + override fun renamePlaylist(playlist: Playlist, name: String) { + val userLibrary = userLibrary ?: return + userLibrary.renamePlaylist(playlist, name) + for (listener in updateListeners) { + listener.onMusicChanges( + MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) + } + } + override fun deletePlaylist(playlist: Playlist) { val userLibrary = userLibrary ?: return userLibrary.deletePlaylist(playlist) 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 75527b6d8..873ed851e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -52,15 +52,20 @@ constructor( /** Flag for opening a dialog to create a playlist of the given [Song]s. */ val newPlaylistSongs: Event> = _newPlaylistSongs - private val _songsToAdd = MutableEvent>() - /** Flag for opening a dialog to add the given [Song]s to a playlist. */ - val songsToAdd: Event> = _songsToAdd + 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 { musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -111,6 +116,20 @@ constructor( } } + /** + * 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) { + musicRepository.renamePlaylist(playlist, name) + } else { + _playlistToRename.put(playlist) + } + } + /** * Delete a [Playlist]. * diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt index 66660bb6d..1cdb8b4db 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -73,7 +73,7 @@ class AddToPlaylistDialog : // --- VIEWMODEL SETUP --- pickerModel.setSongsToAdd(args.songUids) collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs) - collectImmediately(pickerModel.playlistChoices, ::updatePlaylistChoices) + collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices) } override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { 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 index cada8ed00..afc90c825 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt @@ -32,6 +32,7 @@ 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 /** @@ -54,6 +55,7 @@ class DeletePlaylistDialog : ViewBindingDialogFragment(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 _playlistChoices = MutableStateFlow>(listOf()) - val playlistChoices: StateFlow> - get() = _playlistChoices - - private val _currentPlaylistToDelete = MutableStateFlow(null) - val currentPlaylistToDelete: StateFlow - get() = _currentPlaylistToDelete + private val _playlistAddChoices = MutableStateFlow>(listOf()) + /** The [Playlist]s that [currentSongsToAdd] could be added to. */ + val playlistAddChoices: StateFlow> + get() = _playlistAddChoices init { musicRepository.addUpdateListener(this) @@ -124,6 +134,24 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M } } + /** + * 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. * @@ -160,21 +188,12 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M private fun refreshPlaylistChoices(songs: List) { val userLibrary = musicRepository.userLibrary ?: return - _playlistChoices.value = + _playlistAddChoices.value = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map { val songSet = it.songs.toSet() PlaylistChoice(it, songs.all(songSet::contains)) } } - - /** - * 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) - } } /** 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..d3fb58323 --- /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) + .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/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt index afd89de1d..00127a846 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -21,7 +21,6 @@ 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 -import org.oxycblt.auxio.util.update class PlaylistImpl private constructor( @@ -33,6 +32,15 @@ private constructor( 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. * @@ -48,9 +56,14 @@ private constructor( inline fun edit(edits: MutableList.() -> Unit) = edit(songs.toMutableList().apply(edits)) override fun equals(other: Any?) = - other is PlaylistImpl && uid == other.uid && songs == other.songs + other is PlaylistImpl && uid == other.uid && name == other.name && songs == other.songs - override fun hashCode() = 31 * uid.hashCode() + songs.hashCode() + override fun hashCode(): Int { + var hashCode = uid.hashCode() + hashCode = 31 * hashCode + name.hashCode() + hashCode = 31 * hashCode + songs.hashCode() + return hashCode + } companion object { /** @@ -62,7 +75,7 @@ private constructor( */ fun from(name: String, songs: List, musicSettings: MusicSettings) = PlaylistImpl( - Music.UID.auxio(MusicMode.PLAYLISTS) { update(name) }, + Music.UID.auxio(MusicMode.PLAYLISTS), Name.Known.from(name, null, musicSettings), songs) 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 index 7ea2c05b5..563f99316 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -80,6 +80,14 @@ interface MutableUserLibrary : UserLibrary { */ fun createPlaylist(name: String, songs: List) + /** + * Rename a [Playlist]. + * + * @param playlist The [Playlist] to rename. + * @param name The name of the new [Playlist]. + */ + fun renamePlaylist(playlist: Playlist, name: String) + /** * Delete a [Playlist]. * @@ -122,6 +130,13 @@ private class UserLibraryImpl( playlistMap[playlistImpl.uid] = playlistImpl } + @Synchronized + override fun renamePlaylist(playlist: Playlist, name: String) { + val playlistImpl = + requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } + playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) + } + @Synchronized override fun deletePlaylist(playlist: Playlist) { playlistMap.remove(playlist.uid) diff --git a/app/src/main/res/menu/menu_playlist_actions.xml b/app/src/main/res/menu/menu_playlist_actions.xml index 4f852e49f..395ec387b 100644 --- a/app/src/main/res/menu/menu_playlist_actions.xml +++ b/app/src/main/res/menu/menu_playlist_actions.xml @@ -12,6 +12,9 @@ + diff --git a/app/src/main/res/menu/menu_playlist_detail.xml b/app/src/main/res/menu/menu_playlist_detail.xml index c3e30e8b9..05a11b388 100644 --- a/app/src/main/res/menu/menu_playlist_detail.xml +++ b/app/src/main/res/menu/menu_playlist_detail.xml @@ -6,6 +6,9 @@ + diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index a11e936ac..54a1a4f37 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -21,11 +21,14 @@ android:id="@+id/action_new_playlist" app:destination="@id/new_playlist_dialog" /> + android:id="@+id/action_rename_playlist" + app:destination="@id/rename_playlist_dialog" /> + @@ -58,16 +61,13 @@ - + android:name="playlistUid" + app:argType="org.oxycblt.auxio.music.Music$UID" /> + + + + + Playlist Playlists New playlist + Rename + Rename playlist Delete Delete playlist? @@ -165,6 +167,8 @@ Monitoring your music library for changes… Added to queue Playlist created + Playlist renamed + Playlist deleted Added to playlist Developed by Alexander Capehart From 3feee67388892c793e8322f76bd00399170c9fdc Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 18 May 2023 17:11:55 -0600 Subject: [PATCH 66/88] image: key based on object hashcode Key images based on the full object hashcode, alongside the UID. Hopefully this reduces the likelihood of images getting stuck further. --- CHANGELOG.md | 1 + .../java/org/oxycblt/auxio/image/extractor/Components.kt | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 609c11d7a..d9a774917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ be parsed as images - 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 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 3f0ecfb38..12ef10a50 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 @@ -36,12 +36,11 @@ import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* class SongKeyer @Inject constructor() : Keyer { - override fun key(data: Song, options: Options) = - "${data.album.uid}${data.album.songs.hashCode()}" + override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}" } class ParentKeyer @Inject constructor() : Keyer { - override fun key(data: MusicParent, options: Options) = "${data.uid}${data.songs.hashCode()}" + override fun key(data: MusicParent, options: Options) = "${data.uid}${data.hashCode()}" } /** From c0001e0a81731b308a19c5723f16bc9d25b32716 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 18 May 2023 17:14:58 -0600 Subject: [PATCH 67/88] tests: fix mocks Fix unimplemented mock methods. --- .../test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt index 7ae9197b6..600a316d1 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt @@ -70,6 +70,10 @@ open class FakeMusicRepository : MusicRepository { throw NotImplementedError() } + override fun renamePlaylist(playlist: Playlist, name: String) { + throw NotImplementedError() + } + override fun requestIndex(withCache: Boolean) { throw NotImplementedError() } From a153a41f8ded385b6fd194f5fa7f65b367c3f533 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 18 May 2023 18:11:00 -0600 Subject: [PATCH 68/88] detail: fix missing album add to playlist option Add an add to playlist option that should have been done prior but was apparently forgotten. --- app/src/main/res/menu/menu_album_detail.xml | 3 +++ 1 file changed, 3 insertions(+) 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 From 33381f463aa364c7531609497457036af6994997 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 18 May 2023 20:13:33 -0600 Subject: [PATCH 69/88] playback: move drag helper to list Move most of QueueDragCallback to the list module. This is planned to be re-used with the playlist view, so it should be shared. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 4 +- .../list/recycler/MaterialDragCallback.kt | 150 ++++++++++++++++++ .../auxio/playback/queue/QueueAdapter.kt | 31 ++-- .../auxio/playback/queue/QueueDragCallback.kt | 108 +------------ app/src/main/res/layout/fragment_queue.xml | 2 +- ..._queue_song.xml => item_editable_song.xml} | 4 +- app/src/main/res/values-ar-rIQ/strings.xml | 4 +- app/src/main/res/values-be/strings.xml | 4 +- app/src/main/res/values-cs/strings.xml | 4 +- app/src/main/res/values-de/strings.xml | 4 +- app/src/main/res/values-es/strings.xml | 4 +- app/src/main/res/values-gl/strings.xml | 4 +- app/src/main/res/values-hr/strings.xml | 4 +- app/src/main/res/values-in/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 4 +- app/src/main/res/values-ja/strings.xml | 4 +- app/src/main/res/values-ko/strings.xml | 4 +- app/src/main/res/values-lt/strings.xml | 4 +- app/src/main/res/values-ml/strings.xml | 4 +- app/src/main/res/values-nl/strings.xml | 4 +- app/src/main/res/values-pa/strings.xml | 4 +- app/src/main/res/values-pl/strings.xml | 4 +- app/src/main/res/values-pt-rBR/strings.xml | 4 +- app/src/main/res/values-pt-rPT/strings.xml | 4 +- app/src/main/res/values-ru/strings.xml | 4 +- app/src/main/res/values-tr/strings.xml | 4 +- app/src/main/res/values-uk/strings.xml | 4 +- app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- 29 files changed, 222 insertions(+), 169 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt rename app/src/main/res/layout/{item_queue_song.xml => item_editable_song.xml} (96%) 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 127c84468..b676745cd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -158,6 +158,8 @@ constructor( val playlistInstructions: Event get() = _playlistInstructions + private var isEditingPlaylist = false + /** The current [Sort] used for [Song]s in [playlistList]. */ var playlistSongSort: Sort get() = musicSettings.playlistSongSort @@ -412,7 +414,7 @@ constructor( val list = mutableListOf() if (playlist.songs.isNotEmpty()) { - val header = SortHeader(R.string.lbl_songs) + val header = BasicHeader(R.string.lbl_songs) list.add(Divider(header)) list.add(header) if (replace) { 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..a2983755d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2021 Auxio Project + * ExtendedDragCallback.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) { + 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 { + /** 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/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index df4ac8c1d..de1edf36c 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.databinding.ItemEditableSongBinding import org.oxycblt.auxio.list.EditableListListener 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 @@ -96,34 +97,32 @@ 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 editable [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 +class QueueSongViewHolder private constructor(private val binding: ItemEditableSongBinding) : + PlayingIndicatorAdapter.ViewHolder(binding.root), MaterialDragCallback.ViewHolder { + override val root: View + get() = binding.root + + override val body: View get() = binding.body - /** The background view of this [QueueSongViewHolder] that shows the delete icon. */ - val backgroundView: View + override val delete: View get() = binding.background - /** The actual background drawable of this [QueueSongViewHolder] that can be manipulated. */ - val backgroundDrawable = + 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,7 +136,7 @@ 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)) } /** @@ -148,7 +147,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong */ @SuppressLint("ClickableViewAccessibility") fun bind(song: Song, listener: EditableListListener) { - listener.bind(song, this, bodyView, binding.songDragHandle) + listener.bind(song, this, body, binding.songDragHandle) binding.songAlbumCover.bind(song) binding.songName.text = song.name.resolve(binding.context) binding.songInfo.text = song.artists.resolveNames(binding.context) @@ -170,7 +169,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/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" /> @@ -79,7 +79,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="@dimen/spacing_mid_medium" - android:contentDescription="@string/desc_queue_handle" + android:contentDescription="@string/desc_song_handle" app:icon="@drawable/ic_handle_24" app:layout_constraintBottom_toBottomOf="@+id/song_album_cover" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/values-ar-rIQ/strings.xml b/app/src/main/res/values-ar-rIQ/strings.xml index 3844f401a..50e2518d2 100644 --- a/app/src/main/res/values-ar-rIQ/strings.xml +++ b/app/src/main/res/values-ar-rIQ/strings.xml @@ -89,8 +89,8 @@ تغيير وضع التكرار تشغيل او اطفاء الخلط خلط جميع الاغاني - إزالة اغنية من الطابور - نقل اغنية من الطابور + إزالة اغنية من الطابور + نقل اغنية من الطابور تحريك التبويت إزالة كلمة البحث إزالة المجلد المستبعد diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 6a927398e..ce5bc96c2 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -144,7 +144,7 @@ Гэтая папка не падтрымліваецца Немагчыма аднавіць стан Кампазіцыя %d - Перамясціць песню ў чаргу + Перамясціць песню ў чаргу Не знойдзена прыкладання, якое можа справіцца з гэтай задачай Прайграванне або прыпыненне Немагчыма захаваць стан @@ -153,7 +153,7 @@ Змяніць рэжым паўтору Значок Auxio Уключыце або выключыце перамешванне - Выдаліць гэтую песню з чаргі + Выдаліць гэтую песню з чаргі Перамяшаць усе песні Спыніць прайграванне Адкрыйце чаргу diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 4956c0358..4fd532ca5 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9b4f49f1c..d6e98eed5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -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,7 +133,7 @@ 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0dc0c61bf..99cfeb71b 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 diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 8a7227404..a74b4072e 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -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 diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index b183da9dc..b724693bd 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 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 0f15797e2..7a569af56 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 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 686f03f87..1a9610f13 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -8,7 +8,7 @@ 曲の長さ 現在の再生状態を保存 このタブを移動 - この再生待ちの曲を移動 + この再生待ちの曲を移動 日付けがありません すべての曲 @@ -91,7 +91,7 @@ 再生状態を復元できません トラック %d 再生またはポーズ - 再生待ちの曲を除去 + 再生待ちの曲を除去 フォルダを除去 Auxio アイコン アルバムカバー diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 4437eb3b0..e1746f7cd 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -107,8 +107,8 @@ 반복 방식 변경 무작위 재생 켜기 또는 끄기 모든 곡 무작위 재생 - 이 대기열의 곡 제거 - 이 대기열의 곡 이동 + 이 대기열의 곡 제거 + 이 대기열의 곡 이동 이 탭 이동 검색 기록 삭제 폴더 제거 diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 1fa1c648f..4c1eb493a 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 diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index c866a1b81..0ecf86df0 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 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-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 65c74d5ac..55c1e2434 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -137,7 +137,7 @@ Automatycznie odtwórz muzykę po podłączeniu słuchawek (może nie działać na wszystkich urządzeniach) Odśwież muzykę Odśwież bibliotekę muzyczną używając tagów z pamięci cache, jeśli są dostępne - Usuń utwór z kolejki + Usuń utwór z kolejki Preferuj album Automatycznie odśwież FLAC @@ -173,7 +173,7 @@ Automatycznie odśwież bibliotekę po wykryciu zmian (wymaga stałego powiadomienia) Wyklucz Zawrzyj - Zmień pozycję utworu w kolejce + Zmień pozycję utworu w kolejce Przesuń kartę Wizerunek wykonawcy dla %s Ładuję bibliotekę muzyczną… 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-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1b85b95a9..39ff8b565 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -93,8 +93,8 @@ Режим повтора Перемешивание Перемешать все треки - Удалить трек из очереди - Переместить трек в очереди + Удалить трек из очереди + Переместить трек в очереди Переместить вкладку Очистить поисковый запрос Удалить папку diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 93c6c8733..c73aa22c3 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 diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 53d4c2948..c618eb6f9 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -218,8 +218,8 @@ Невідомий жанр Відкрити чергу Жовтий - Перемістити пісню в черзі - Видалити пісню з черги + Перемістити пісню в черзі + Видалити пісню з черги Блакитний Зеленувато-блакитний Фіолетовий diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index db6995078..ff969df42 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 @@ 更改重复播放模式 开启或关闭随机播放模式 随机播放所有曲目 - 移除队列曲目 - 移动队列曲目 + 移除队列曲目 + 移动队列曲目 移动该标签 清除搜索队列 移除文件夹 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7bcf57cb..ecf466dda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -311,8 +311,8 @@ 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 From 996c86b361334abfce098f400903c1846c3e2b6c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 May 2023 11:15:33 -0600 Subject: [PATCH 70/88] detail: add playlist editing Add the ability to edit a playlist in it's detail view. This replaces the prior sorting functionality entirely. That will be re-added later. --- .../java/org/oxycblt/auxio/IntegerTable.kt | 6 + .../oxycblt/auxio/detail/DetailViewModel.kt | 113 ++++++-- .../auxio/detail/PlaylistDetailFragment.kt | 104 ++++--- .../auxio/detail/list/DetailListAdapter.kt | 6 +- .../detail/list/PlaylistDetailListAdapter.kt | 263 +++++++++++++++++- .../auxio/detail/list/PlaylistDragCallback.kt | 42 +++ .../org/oxycblt/auxio/home/tabs/TabAdapter.kt | 10 +- .../auxio/home/tabs/TabCustomizeDialog.kt | 4 +- .../auxio/image/extractor/Components.kt | 1 + .../java/org/oxycblt/auxio/list/Listeners.kt | 35 ++- .../main/java/org/oxycblt/auxio/list/Sort.kt | 15 - .../list/recycler/MaterialDragCallback.kt | 6 +- .../list/selection/SelectionViewModel.kt | 2 +- .../oxycblt/auxio/music/MusicRepository.kt | 17 ++ .../org/oxycblt/auxio/music/MusicSettings.kt | 15 - .../auxio/music/system/IndexerService.kt | 2 +- .../oxycblt/auxio/music/user/UserLibrary.kt | 15 + .../auxio/playback/PlaybackViewModel.kt | 18 +- .../auxio/playback/queue/QueueAdapter.kt | 27 +- .../auxio/playback/queue/QueueFragment.kt | 4 +- app/src/main/res/drawable/ic_edit_24.xml | 11 + app/src/main/res/layout/item_edit_header.xml | 50 ++++ .../main/res/layout/item_editable_song.xml | 13 + app/src/main/res/layout/item_sort_header.xml | 3 +- app/src/main/res/values/strings.xml | 1 + .../auxio/music/FakeMusicRepository.kt | 6 +- .../oxycblt/auxio/music/FakeMusicSettings.kt | 3 - 27 files changed, 634 insertions(+), 158 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDragCallback.kt create mode 100644 app/src/main/res/drawable/ic_edit_24.xml create mode 100644 app/src/main/res/layout/item_edit_header.xml diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 93b8b239a..db14d5882 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -49,6 +49,12 @@ object IntegerTable { const val VIEW_TYPE_ARTIST_SONG = 0xA00A /** DiscHeaderViewHolder */ const val VIEW_TYPE_DISC_HEADER = 0xA00B + /** EditHeaderViewHolder */ + const val VIEW_TYPE_EDIT_HEADER = 0xA00C + /** ConfirmHeaderViewHolder */ + const val VIEW_TYPE_CONFIRM_HEADER = 0xA00D + /** EditableSongViewHolder */ + const val VIEW_TYPE_EDITABLE_SONG = 0xA00E /** "Music playback" notification code */ const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0 /** "Music loading" notification code */ 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 b676745cd..ccaf2c9e1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -22,6 +22,7 @@ import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import java.lang.Exception import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -30,6 +31,7 @@ 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 @@ -145,6 +147,7 @@ constructor( } // --- PLAYLIST --- + private val _currentPlaylist = MutableStateFlow(null) /** The current [Playlist] to display. Null if there is nothing to do. */ val currentPlaylist: StateFlow @@ -158,16 +161,13 @@ constructor( val playlistInstructions: Event get() = _playlistInstructions - private var isEditingPlaylist = false - - /** The current [Sort] used for [Song]s in [playlistList]. */ - var playlistSongSort: Sort - get() = musicSettings.playlistSongSort - set(value) { - musicSettings.playlistSongSort = value - // Refresh the playlist list to reflect the new sort. - currentPlaylist.value?.let { refreshPlaylistList(it, true) } - } + 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 @@ -220,6 +220,7 @@ constructor( 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) } @@ -285,6 +286,71 @@ constructor( 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 confirmPlaylistEdit() { + val playlist = _currentPlaylist.value ?: return + val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null } + musicRepository.rewritePlaylist(playlist, editedPlaylist) + } + + /** + * End a playlist editing session and keep the prior state. Does nothing if there was no prior + * editing session. + */ + fun dropPlaylistEdit() { + val playlist = _currentPlaylist.value ?: return + _editedPlaylist.value = null + refreshPlaylistList(playlist) + } + + /** + * (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. + */ + fun movePlaylistSongs(from: Int, to: Int): Boolean { + 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, UpdateInstructions.Remove(at)) + } + private fun refreshAudioInfo(song: Song) { // Clear any previous job in order to avoid stale data from appearing in the UI. currentSongJob?.cancel() @@ -408,21 +474,26 @@ constructor( _genreList.value = list } - private fun refreshPlaylistList(playlist: Playlist, replace: Boolean = false) { + private fun refreshPlaylistList( + playlist: Playlist, + instructions: UpdateInstructions = UpdateInstructions.Diff + ) { + logD(Exception().stackTraceToString()) logD("Refreshing playlist list") - var instructions: UpdateInstructions = UpdateInstructions.Diff val list = mutableListOf() - if (playlist.songs.isNotEmpty()) { - val header = BasicHeader(R.string.lbl_songs) - list.add(Divider(header)) - list.add(header) - if (replace) { - instructions = UpdateInstructions.Replace(list.size) + val newInstructions = + if (playlist.songs.isNotEmpty()) { + val header = EditHeader(R.string.lbl_songs) + list.add(Divider(header)) + list.add(header) + list.addAll(_editedPlaylist.value ?: playlist.songs) + instructions + } else { + UpdateInstructions.Diff } - list.addAll(playlistSongSort.songs(playlist.songs)) - } - _playlistInstructions.put(instructions) + + _playlistInstructions.put(newInstructions) _playlistList.value = list } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 9b7d78b5c..42f1d2e91 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -23,23 +23,26 @@ 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.DetailListAdapter 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.Sort import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.* import org.oxycblt.auxio.navigation.NavigationViewModel @@ -55,7 +58,8 @@ import org.oxycblt.auxio.util.* class PlaylistDetailFragment : ListFragment(), DetailHeaderAdapter.Listener, - DetailListAdapter.Listener { + PlaylistDetailListAdapter.Listener, + NavController.OnDestinationChangedListener { private val detailModel: DetailViewModel by activityViewModels() override val navModel: NavigationViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() @@ -66,6 +70,8 @@ class PlaylistDetailFragment : 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) @@ -92,6 +98,10 @@ class 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[it - 1] @@ -107,21 +117,52 @@ class PlaylistDetailFragment : detailModel.setPlaylistUid(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.detailToolbar.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. + detailModel.dropPlaylistEdit() + } + override fun onMenuItemClick(item: MenuItem): Boolean { if (super.onMenuItemClick(item)) { return true @@ -155,7 +196,12 @@ class PlaylistDetailFragment : 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) { + // TODO: Remove "Add to playlist" option, makes no sense openMusicMenu(anchor, R.menu.menu_song_actions, item) } @@ -167,39 +213,21 @@ class PlaylistDetailFragment : playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value)) } - override fun onOpenSortMenu(anchor: View) { - openMenu(anchor, R.menu.menu_playlist_sort) { - // Select the corresponding sort mode option - val sort = detailModel.playlistSongSort - 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 - Sort.Direction.DESCENDING -> R.id.option_sort_dec - } - unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true - // If there is no sort specified, disable the ascending/descending options, as - // they make no sense. We still do want to indicate the state however, in the case - // that the user wants to switch to a sort mode where they do make sense. - if (sort.mode is Sort.Mode.ByNone) { - menu.findItem(R.id.option_sort_dec).isEnabled = false - menu.findItem(R.id.option_sort_asc).isEnabled = false - } + override fun onStartEdit() { + selectionModel.drop() + detailModel.startPlaylistEdit() + } - setOnMenuItemClickListener { item -> - item.isChecked = !item.isChecked - detailModel.playlistSongSort = - 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 - } - } + override fun onConfirmEdit() { + detailModel.confirmPlaylistEdit() + } + + override fun onDropEdit() { + detailModel.dropPlaylistEdit() + } + + override fun onOpenSortMenu(anchor: View) { + throw IllegalStateException() } private fun updatePlaylist(playlist: Playlist?) { @@ -250,6 +278,12 @@ class PlaylistDetailFragment : playlistListAdapter.update(list, detailModel.playlistInstructions.consume()) } + private fun updateEditedPlaylist(editedPlaylist: List?) { + // TODO: Disable check item when no edits have been made + // TODO: Improve how this state change looks + playlistListAdapter.setEditing(editedPlaylist != null) + } + private fun updateSelection(selected: List) { playlistListAdapter.setSelected(selected.toSet()) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) 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 cd23751be..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 @@ -111,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) */ @@ -126,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/PlaylistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt index 5a33e511f..c24683c0e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -18,53 +18,286 @@ 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.isGone +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 and sub-items for the [Playlist] detail view. + * 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) : +class PlaylistDetailListAdapter(private val listener: Listener) : DetailListAdapter(listener, DIFF_CALLBACK) { + private var isEditing = false + override fun getItemViewType(position: Int) = when (getItem(position)) { - // Support generic song items. - is Song -> SongViewHolder.VIEW_TYPE + is EditHeader -> EditHeaderViewHolder.VIEW_TYPE + is Song -> PlaylistSongViewHolder.VIEW_TYPE else -> super.getItemViewType(position) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - if (viewType == SongViewHolder.VIEW_TYPE) { - SongViewHolder.from(parent) - } else { - super.onCreateViewHolder(parent, viewType) + 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) { - super.onBindViewHolder(holder, position) - val item = getItem(position) - if (item is Song) { - (holder as SongViewHolder).bind(item, listener) + 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) } } - companion object { + fun setEditing(editing: Boolean) { + if (editing == isEditing) { + // Nothing to do. + return + } + this.isEditing = editing + notifyItemRangeChanged(1, currentList.size - 2, 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() + /** Called when the "confirm" option is selected in the edit header. */ + fun onConfirmEdit() + /** Called when the "cancel" option is selected in the edit header. */ + fun onDropEdit() + } + + /** + * 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 -> - SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + 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 [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create + * an instance. + * + * @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. */ +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() } + } + binding.headerConfirm.apply { + TooltipCompat.setTooltipText(this, contentDescription) + setOnClickListener { listener.onConfirmEdit() } + } + binding.headerCancel.apply { + TooltipCompat.setTooltipText(this, contentDescription) + setOnClickListener { listener.onDropEdit() } + } + } + + override fun updateEditing(editing: Boolean) { + binding.headerEdit.apply { + isGone = editing + jumpDrawablesToCurrentState() + } + binding.headerConfirm.apply { + isVisible = editing + jumpDrawablesToCurrentState() + } + binding.headerCancel.apply { + isVisible = editing + jumpDrawablesToCurrentState() + } + } + + 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.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.apply { + isClickable = !editing + isFocusable = !editing + } + } + + companion object { + /** A unique ID for this [RecyclerView.ViewHolder] type. */ + const val VIEW_TYPE = IntegerTable.VIEW_TYPE_EDITABLE_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/tabs/TabAdapter.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt index a1b9db7fe..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 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/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 12ef10a50..f557c6946 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 @@ -39,6 +39,7 @@ class SongKeyer @Inject constructor() : Keyer { override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}" } +// TODO: Key on the actual mosaic items used class ParentKeyer @Inject constructor() : Keyer { override fun key(data: MusicParent, options: Options) = "${data.uid}${data.hashCode()}" } 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 808a8d150..5002e60cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt @@ -206,19 +206,6 @@ data class Sort(val mode: Mode, val direction: Direction) { */ fun getPlaylistComparator(direction: Direction): Comparator? = null - /** - * Sort by the item's natural order. - * - * @see Music.name - */ - object ByNone : Mode { - override val intCode: Int - get() = IntegerTable.SORT_BY_NONE - - override val itemId: Int - get() = R.id.option_sort_none - } - /** * Sort by the item's name. * @@ -455,7 +442,6 @@ data class Sort(val mode: Mode, val direction: Direction) { */ fun fromIntCode(intCode: Int) = when (intCode) { - ByNone.intCode -> ByNone ByName.intCode -> ByName ByArtist.intCode -> ByArtist ByAlbum.intCode -> ByAlbum @@ -477,7 +463,6 @@ data class Sort(val mode: Mode, val direction: Direction) { */ fun fromItemId(@IdRes itemId: Int) = when (itemId) { - ByNone.itemId -> ByNone ByName.itemId -> ByName ByAlbum.itemId -> ByAlbum ByArtist.itemId -> ByArtist 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 index a2983755d..ea5629e78 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2021 Auxio Project - * ExtendedDragCallback.kt is part of Auxio. + * 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 @@ -44,7 +44,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() { recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ) = - if (viewHolder is 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) @@ -138,6 +138,8 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() { /** 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. */ 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 5c772f519..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 @@ -96,7 +96,7 @@ constructor( is Album -> musicSettings.albumSongSort.songs(it.songs) is Artist -> musicSettings.artistSongSort.songs(it.songs) is Genre -> musicSettings.genreSongSort.songs(it.songs) - is Playlist -> musicSettings.playlistSongSort.songs(it.songs) + is Playlist -> it.songs } } .also { drop() } 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 91ad069fb..8d7c64f7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -141,6 +141,14 @@ interface MusicRepository { */ 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]. + */ + 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. @@ -304,6 +312,15 @@ constructor( } } + override fun rewritePlaylist(playlist: Playlist, songs: List) { + val userLibrary = userLibrary ?: return + userLibrary.rewritePlaylist(playlist, songs) + for (listener in updateListeners) { + listener.onMusicChanges( + MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) + } + } + override fun requestIndex(withCache: Boolean) { indexingWorker?.requestIndex(withCache) } 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 adcf337c0..48b180388 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -63,8 +63,6 @@ interface MusicSettings : Settings { var artistSongSort: Sort /** The [Sort] mode used in a [Genre]'s [Song] list. */ var genreSongSort: Sort - /** The [Sort] mode used in a [Playlist]'s [Song] list. */ - var playlistSongSort: Sort interface Listener { /** Called when a setting controlling how music is loaded has changed. */ @@ -225,19 +223,6 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context } } - override var playlistSongSort: Sort - get() = - Sort.fromIntCode( - sharedPreferences.getInt( - getString(R.string.set_key_playlist_songs_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByNone, Sort.Direction.ASCENDING) - set(value) { - sharedPreferences.edit { - putInt(getString(R.string.set_key_playlist_songs_sort), value.intCode) - apply() - } - } - override fun onSettingChanged(key: String, listener: MusicSettings.Listener) { // TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads" // (just need to manipulate data) 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 56514f7dd..557dda7c4 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 @@ -131,7 +131,7 @@ class IndexerService : override val scope = indexScope override fun onMusicChanges(changes: MusicRepository.Changes) { - if (!changes.deviceLibrary) return + // TODO: Do not pause when playlist changes val deviceLibrary = musicRepository.deviceLibrary ?: return // Wipe possibly-invalidated outdated covers imageLoader.memoryCache?.clear() 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 index 563f99316..0760a6f4a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -101,6 +101,14 @@ interface MutableUserLibrary : UserLibrary { * @param playlist The [Playlist] to add to. Must currently exist. */ 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]. + */ + fun rewritePlaylist(playlist: Playlist, songs: List) } class UserLibraryFactoryImpl @@ -148,4 +156,11 @@ private class UserLibraryImpl( requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } } + + @Synchronized + override fun rewritePlaylist(playlist: Playlist, songs: List) { + val playlistImpl = + requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" } + playlistMap[playlist.uid] = playlistImpl.edit(songs) + } } 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 81ea0d121..68f2cac16 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -306,16 +306,14 @@ constructor( "Song to play not in parent" } val deviceLibrary = musicRepository.deviceLibrary ?: return - val sort = + val queue = when (parent) { - is Genre -> musicSettings.genreSongSort - is Artist -> musicSettings.artistSongSort - is Album -> musicSettings.albumSongSort - is Playlist -> musicSettings.playlistSongSort - 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 songs = parent?.songs ?: deviceLibrary.songs - val queue = sort.songs(songs) playbackManager.play(song, parent, queue, shuffled) } @@ -394,7 +392,7 @@ constructor( * @param playlist The [Playlist] to add. */ fun playNext(playlist: Playlist) { - playbackManager.playNext(musicSettings.playlistSongSort.songs(playlist.songs)) + playbackManager.playNext(playlist.songs) } /** @@ -448,7 +446,7 @@ constructor( * @param playlist The [Playlist] to add. */ fun addToQueue(playlist: Playlist) { - playbackManager.addToQueue(musicSettings.playlistSongSort.songs(playlist.songs)) + playbackManager.addToQueue(playlist.songs) } /** 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 de1edf36c..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 @@ -27,7 +27,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemEditableSongBinding -import org.oxycblt.auxio.list.EditableListListener +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 @@ -38,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 @@ -97,22 +97,17 @@ class QueueAdapter(private val listener: EditableListListener) : } /** - * A [PlayingIndicatorAdapter.ViewHolder] that displays an editable [Song] which can be re-ordered - * and removed. 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: ItemEditableSongBinding) : PlayingIndicatorAdapter.ViewHolder(binding.root), MaterialDragCallback.ViewHolder { - override val root: View - get() = binding.root - - override val body: View - get() = binding.body - - override val delete: View - get() = binding.background - + 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) @@ -143,10 +138,10 @@ class QueueSongViewHolder private constructor(private val binding: ItemEditableS * 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) { + fun bind(song: Song, listener: EditClickListListener) { listener.bind(song, this, body, binding.songDragHandle) binding.songAlbumCover.bind(song) binding.songName.text = song.name.resolve(binding.context) 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/res/drawable/ic_edit_24.xml b/app/src/main/res/drawable/ic_edit_24.xml new file mode 100644 index 000000000..7d2e3e617 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_24.xml @@ -0,0 +1,11 @@ + + + + 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..3b999323e --- /dev/null +++ b/app/src/main/res/layout/item_edit_header.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_editable_song.xml b/app/src/main/res/layout/item_editable_song.xml index c8b8dd5a6..9cfa194eb 100644 --- a/app/src/main/res/layout/item_editable_song.xml +++ b/app/src/main/res/layout/item_editable_song.xml @@ -85,6 +85,19 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@+id/song_album_cover" /> + + 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 @@ - Rename playlist Delete Delete playlist? + Edit Search diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt index 600a316d1..735f2fc02 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt @@ -62,6 +62,10 @@ open class FakeMusicRepository : MusicRepository { throw NotImplementedError() } + override fun renamePlaylist(playlist: Playlist, name: String) { + throw NotImplementedError() + } + override fun deletePlaylist(playlist: Playlist) { throw NotImplementedError() } @@ -70,7 +74,7 @@ open class FakeMusicRepository : MusicRepository { throw NotImplementedError() } - override fun renamePlaylist(playlist: Playlist, name: String) { + override fun rewritePlaylist(playlist: Playlist, songs: List) { 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 7ad814fc7..66cd8e880 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt @@ -60,7 +60,4 @@ open class FakeMusicSettings : MusicSettings { override var genreSongSort: Sort get() = throw NotImplementedError() set(_) = throw NotImplementedError() - override var playlistSongSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() } From 5fff1bd0b329a97371ceef0679d9a132c27b3553 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 May 2023 14:09:40 -0600 Subject: [PATCH 71/88] image: simplify implementation Reduce the accepted datatype of extractors down to a list of songs, moving the other datatypes to the UI layer. This massively reduces the amount of components that must be managed, and enables functionality related to playlist editing. --- .../auxio/detail/PlaylistDetailFragment.kt | 8 +- .../org/oxycblt/auxio/image/BitmapProvider.kt | 2 +- .../org/oxycblt/auxio/image/ImageGroup.kt | 3 + .../oxycblt/auxio/image/StyledImageView.kt | 10 +- .../auxio/image/extractor/Components.kt | 156 ++---------------- .../auxio/image/extractor/CoverExtractor.kt | 93 ++++++++++- .../auxio/image/extractor/ExtractorModule.kt | 12 +- .../oxycblt/auxio/image/extractor/Images.kt | 118 ------------- .../list/selection/SelectionToolbarOverlay.kt | 2 + .../oxycblt/auxio/music/user/UserLibrary.kt | 2 +- .../oxycblt/auxio/music/user/UserModule.kt | 6 +- ...aylistDatabase.kt => UserMusicDatabase.kt} | 4 +- 12 files changed, 129 insertions(+), 287 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt rename app/src/main/java/org/oxycblt/auxio/music/user/{PlaylistDatabase.kt => UserMusicDatabase.kt} (92%) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 42f1d2e91..0d3bc3d9d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -280,7 +280,13 @@ class PlaylistDetailFragment : private fun updateEditedPlaylist(editedPlaylist: List?) { // TODO: Disable check item when no edits have been made - // TODO: Improve how this state change looks + + // TODO: Massively improve how this UI is indicated: + // - Make playlist header dynamically respond to song changes + // - Disable play and pause buttons + // - Add an additional toolbar to indicate editing + // - Header should flip to re-sort button eventually + playlistListAdapter.setEditing(editedPlaylist != null) } 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 3f8652a7c..449f489fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt @@ -49,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 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 3f9f58671..9c6b137d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt @@ -96,7 +96,7 @@ 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. @@ -130,15 +130,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr /** * Internally bind a [Music]'s image to this view. * - * @param music The music to find. + * @param parent The music to bind, in the form of it's [MusicParent]s. * @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]. */ - private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) { + private fun bindImpl(parent: MusicParent, @DrawableRes errorRes: Int, @StringRes descRes: Int) { val request = ImageRequest.Builder(context) - .data(music) + .data(parent.songs) .error(StyledDrawable(context, context.getDrawableCompat(errorRes))) .transformations(SquareFrameTransform.INSTANCE) .target(this) @@ -147,7 +147,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr CoilUtils.dispose(this) imageLoader.enqueue(request) // Update the content description to the specified resource. - contentDescription = context.getString(descRes, music.name.resolve(context)) + contentDescription = context.getString(descRes, parent.name.resolve(context)) } /** 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 f557c6946..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,163 +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.* -class SongKeyer @Inject constructor() : Keyer { - override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}" +class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) : + Keyer> { + override fun key(data: List, options: Options) = + "${coverExtractor.computeAlbumOrdering(data).hashCode()}" } -// TODO: Key on the actual mosaic items used -class ParentKeyer @Inject constructor() : Keyer { - override fun key(data: MusicParent, options: Options) = "${data.uid}${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) - } - - 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) - } - - 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) - 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) - } - - 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) - } -} - -/** - * [Fetcher] for [Playlist] images. Use [Factory] for instantiation. - * - * @author Alexander Capehart (OxygenCobalt) - */ -class PlaylistImageFetcher -private constructor( - private val context: Context, - private val extractor: CoverExtractor, - private val size: Size, - private val playlist: Playlist -) : Fetcher { - override suspend fun fetch(): FetchResult? { - val results = playlist.albums.mapAtMostNotNull(4) { album -> extractor.extract(album) } - return Images.createMosaic(context, results, size) - } - - class Factory @Inject constructor(private val extractor: CoverExtractor) : - Fetcher.Factory { - override fun create(data: Playlist, options: Options, imageLoader: ImageLoader) = - PlaylistImageFetcher(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 a89931fba..6b1965c58 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 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,7 +62,28 @@ constructor( private val imageSettings: ImageSettings, private val mediaSourceFactory: MediaSource.Factory ) { - suspend fun extract(album: Album): InputStream? = + suspend fun extract(songs: List, size: Size): FetchResult? { + val albums = computeAlbumOrdering(songs) + val streams = mutableListOf() + for (album in albums) { + if (streams.size == 4) { + return createMosaic(streams, size) + } + openInputStream(album)?.let(streams::add) + } + + return streams.firstOrNull()?.let { stream -> + SourceResult( + source = ImageSource(stream.source().buffer(), context), + mimeType = null, + dataSource = DataSource.DISK) + } + } + + fun computeAlbumOrdering(songs: List): Collection = + songs.groupByTo(sortedMapOf(compareByDescending { it.songs.size })) { it.album }.keys + + private suspend fun openInputStream(album: Album): InputStream? = try { when (imageSettings.coverMode) { CoverMode.OFF -> null @@ -125,4 +162,58 @@ constructor( 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 index 82ec32e07..5f4145479 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt @@ -36,23 +36,13 @@ class ExtractorModule { fun imageLoader( @ApplicationContext context: Context, songKeyer: SongKeyer, - parentKeyer: ParentKeyer, - songFactory: AlbumCoverFetcher.SongFactory, - albumFactory: AlbumCoverFetcher.AlbumFactory, - artistFactory: ArtistImageFetcher.Factory, - genreFactory: GenreImageFetcher.Factory, - playlistFactory: PlaylistImageFetcher.Factory + songFactory: SongCoverFetcher.Factory ) = ImageLoader.Builder(context) .components { // Add fetchers for Music components to make them usable with ImageRequest add(songKeyer) - add(parentKeyer) add(songFactory) - add(albumFactory) - add(artistFactory) - add(genreFactory) - add(playlistFactory) } // Use our own crossfade with error drawable support .transitionFactory(ErrorCrossfadeTransitionFactory()) 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/selection/SelectionToolbarOverlay.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt index 05b203771..7db98bb97 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt @@ -35,6 +35,8 @@ import org.oxycblt.auxio.util.logD * current selection state. * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Generalize this into a "view flipper" class and then derive it through other means? */ class SelectionToolbarOverlay @JvmOverloads 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 index 0760a6f4a..7962533de 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -147,7 +147,7 @@ private class UserLibraryImpl( @Synchronized override fun deletePlaylist(playlist: Playlist) { - playlistMap.remove(playlist.uid) + requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" } } @Synchronized 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 index b4c7ef6a4..618babd4d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt @@ -36,12 +36,12 @@ interface UserModule { @Module @InstallIn(SingletonComponent::class) class UserRoomModule { - @Provides fun playlistDao(database: PlaylistDatabase) = database.playlistDao() + @Provides fun playlistDao(database: UserMusicDatabase) = database.playlistDao() @Provides - fun playlistDatabase(@ApplicationContext context: Context) = + fun userMusicDatabase(@ApplicationContext context: Context) = Room.databaseBuilder( - context.applicationContext, PlaylistDatabase::class.java, "playlists.db") + context.applicationContext, UserMusicDatabase::class.java, "user_music.db") .fallbackToDestructiveMigration() .fallbackToDestructiveMigrationFrom(0) .fallbackToDestructiveMigrationOnDowngrade() diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt similarity index 92% rename from app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt rename to app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt index 3377b172a..361d4f85f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * PlaylistDatabase.kt is part of Auxio. + * 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 @@ -26,7 +26,7 @@ import org.oxycblt.auxio.music.Music version = 28, exportSchema = false) @TypeConverters(Music.UID.TypeConverters::class) -abstract class PlaylistDatabase : RoomDatabase() { +abstract class UserMusicDatabase : RoomDatabase() { abstract fun playlistDao(): PlaylistDao } From cee92c80876a19ab194d50416bffce8d6dbcb280 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 May 2023 14:33:49 -0600 Subject: [PATCH 72/88] detail: update playlist header to reflect edits Make the header information reflect changes in playlist composition as the playlist is edited. This should improve the editing experience to some extent. --- .../auxio/detail/PlaylistDetailFragment.kt | 3 +- .../detail/header/DetailHeaderAdapter.kt | 7 +++ .../header/PlaylistDetailHeaderAdapter.kt | 55 +++++++++++++------ .../detail/list/PlaylistDetailListAdapter.kt | 5 +- .../oxycblt/auxio/image/StyledImageView.kt | 40 ++++++++------ app/src/main/res/drawable/ic_edit_24.xml | 2 +- 6 files changed, 70 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 0d3bc3d9d..18ecf7956 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -282,12 +282,11 @@ class PlaylistDetailFragment : // TODO: Disable check item when no edits have been made // TODO: Massively improve how this UI is indicated: - // - Make playlist header dynamically respond to song changes - // - Disable play and pause buttons // - Add an additional toolbar to indicate editing // - Header should flip to re-sort button eventually playlistListAdapter.setEditing(editedPlaylist != null) + playlistHeaderAdapter.setEditedPlaylist(editedPlaylist) } private fun updateSelection(selected: List) { 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 36a30fe24..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,6 +48,13 @@ abstract class 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, listener) + 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() + } } /** @@ -58,35 +75,39 @@ private constructor(private val binding: ItemDetailHeaderBinding) : * 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, listener: DetailHeaderAdapter.Listener) { - binding.detailCover.bind(playlist) + fun bind( + playlist: Playlist, + editedPlaylist: List?, + listener: DetailHeaderAdapter.Listener + ) { + 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.apply { - isVisible = true - text = - if (playlist.songs.isNotEmpty()) { - binding.context.getString( - R.string.fmt_two, - binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size), - playlist.durationMs.formatDurationMs(true)) - } else { - binding.context.getString(R.string.def_song_count) - } - } + 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() + isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null setOnClickListener { listener.onPlay() } } binding.detailShuffleButton.apply { - isEnabled = playlist.songs.isNotEmpty() + isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null setOnClickListener { listener.onShuffle() } } } 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 index c24683c0e..75306da26 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -278,10 +278,7 @@ private constructor(private val binding: ItemEditableSongBinding) : override fun updateEditing(editing: Boolean) { binding.songDragHandle.isInvisible = !editing binding.songMenu.isInvisible = editing - binding.interactBody.apply { - isClickable = !editing - isFocusable = !editing - } + binding.interactBody.isEnabled = !editing } companion object { 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 9c6b137d3..2e04617e5 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt @@ -103,42 +103,47 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * * @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) /** * Bind a [Playlist]'s image to this view, also updating the content description. * - * @param playlist the [Playlist] to bind. + * @param playlist The [Playlist] to bind. + * @param songs [Song]s that can override the playlist image if it needs to differ for any + * reason. */ - fun bind(playlist: Playlist) = - bindImpl(playlist, R.drawable.ic_playlist_24, R.string.desc_playlist_image) + 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) + } - /** - * Internally bind a [Music]'s image to this view. - * - * @param parent The music to bind, in the form of it's [MusicParent]s. - * @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]. - */ - private fun bindImpl(parent: MusicParent, @DrawableRes errorRes: Int, @StringRes descRes: Int) { + 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(parent.songs) + .data(songs) .error(StyledDrawable(context, context.getDrawableCompat(errorRes))) .transformations(SquareFrameTransform.INSTANCE) .target(this) @@ -146,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, parent.name.resolve(context)) + contentDescription = desc } /** diff --git a/app/src/main/res/drawable/ic_edit_24.xml b/app/src/main/res/drawable/ic_edit_24.xml index 7d2e3e617..9ce54759b 100644 --- a/app/src/main/res/drawable/ic_edit_24.xml +++ b/app/src/main/res/drawable/ic_edit_24.xml @@ -7,5 +7,5 @@ android:tint="?attr/colorControlNormal"> + android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L715,76L884,245L772,357ZM120,840L120,670L544,246L714,416L290,840L120,840ZM573,387L545,359L545,359L601,415L601,415L573,387Z"/> From 1fd6795b0d32749dafb72c7f06830f5926baf824 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 May 2023 19:54:36 -0600 Subject: [PATCH 73/88] detail: move editing state to toolbar Move the music editing state to the toolbar. This should be signifigantly clearer than prior, at the cost of it's "universality" implying that renaming should be available when it actually won't be. --- .../java/org/oxycblt/auxio/MainFragment.kt | 8 + .../auxio/detail/AlbumDetailFragment.kt | 15 +- .../auxio/detail/ArtistDetailFragment.kt | 15 +- .../auxio/detail/DetailAppBarLayout.kt | 2 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 18 +- .../auxio/detail/GenreDetailFragment.kt | 15 +- .../auxio/detail/PlaylistDetailFragment.kt | 64 ++++--- .../detail/list/PlaylistDetailListAdapter.kt | 21 +-- .../org/oxycblt/auxio/home/HomeFragment.kt | 25 +-- .../auxio/image/extractor/CoverExtractor.kt | 5 +- .../auxio/list/selection/SelectionFragment.kt | 11 +- .../list/selection/SelectionToolbarOverlay.kt | 178 ------------------ .../oxycblt/auxio/search/SearchFragment.kt | 18 +- .../java/org/oxycblt/auxio/ui/MultiToolbar.kt | 114 +++++++++++ app/src/main/res/drawable/ic_save_24.xml | 11 ++ app/src/main/res/layout/fragment_detail.xml | 27 ++- app/src/main/res/layout/fragment_home.xml | 17 +- app/src/main/res/layout/fragment_search.xml | 17 +- app/src/main/res/layout/item_edit_header.xml | 13 +- app/src/main/res/menu/menu_edit_actions.xml | 9 + 20 files changed, 317 insertions(+), 286 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt create mode 100644 app/src/main/res/drawable/ic_save_24.xml create mode 100644 app/src/main/res/menu/menu_edit_actions.xml diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 90ada8b9c..665fc7bdc 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -38,6 +38,7 @@ 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 @@ -66,6 +67,7 @@ class MainFragment : 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 @@ -458,6 +460,11 @@ class MainFragment : return } + // Clear out pending playlist edits. + if (detailModel.dropPlaylistEdit()) { + return + } + // Clear out any prior selections. if (selectionModel.drop()) { return @@ -487,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 b168f1afe..d1f13160c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -93,7 +93,7 @@ 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) @@ -124,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. @@ -214,7 +214,7 @@ class AlbumDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = album.name.resolve(requireContext()) + requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext()) albumHeaderAdapter.setParent(album) } @@ -313,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 20b055183..619a48211 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -91,7 +91,7 @@ class ArtistDetailFragment : super.onBindingCreated(binding, savedInstanceState) // --- UI SETUP --- - binding.detailToolbar.apply { + binding.detailNormalToolbar.apply { inflateMenu(R.menu.menu_parent_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@ArtistDetailFragment) @@ -122,7 +122,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. @@ -223,7 +223,7 @@ class ArtistDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = artist.name.resolve(requireContext()) + requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext()) artistHeaderAdapter.setParent(artist) } @@ -283,6 +283,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 ae1325daf..15b803ae6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt @@ -69,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 ccaf2c9e1..367ef3545 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -22,7 +22,6 @@ import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import java.lang.Exception import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -298,7 +297,7 @@ constructor( * End a playlist editing session and commits it to the database. Does nothing if there was no * prior editing session. */ - fun confirmPlaylistEdit() { + fun savePlaylistEdit() { val playlist = _currentPlaylist.value ?: return val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null } musicRepository.rewritePlaylist(playlist, editedPlaylist) @@ -307,11 +306,18 @@ constructor( /** * 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() { - val playlist = _currentPlaylist.value ?: return + 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 } /** @@ -319,8 +325,10 @@ constructor( * * @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 @@ -340,6 +348,7 @@ constructor( * @param at The position of the item to remove, in the list adapter data. */ fun removePlaylistSong(at: Int) { + // TODO: Remove header when empty val playlist = _currentPlaylist.value ?: return val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList() val realAt = at - 2 @@ -478,7 +487,6 @@ constructor( playlist: Playlist, instructions: UpdateInstructions = UpdateInstructions.Diff ) { - logD(Exception().stackTraceToString()) logD("Refreshing playlist list") val list = mutableListOf() 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 2729267f7..862d5d2ef 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -84,7 +84,7 @@ class GenreDetailFragment : super.onBindingCreated(binding, savedInstanceState) // --- UI SETUP --- - binding.detailToolbar.apply { + binding.detailNormalToolbar.apply { inflateMenu(R.menu.menu_parent_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@GenreDetailFragment) @@ -115,7 +115,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. @@ -214,7 +214,7 @@ class GenreDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = genre.name.resolve(requireContext()) + requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext()) genreHeaderAdapter.setParent(genre) } @@ -260,6 +260,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 index 18ecf7956..4b8c2650c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -90,12 +90,17 @@ class PlaylistDetailFragment : super.onBindingCreated(binding, savedInstanceState) // --- UI SETUP --- - binding.detailToolbar.apply { + 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 = @@ -139,7 +144,7 @@ class PlaylistDetailFragment : override fun onDestroyBinding(binding: FragmentDetailBinding) { super.onDestroyBinding(binding) - binding.detailToolbar.setOnMenuItemClickListener(null) + binding.detailNormalToolbar.setOnMenuItemClickListener(null) touchHelper = null binding.detailRecycler.adapter = null // Avoid possible race conditions that could cause a bad replace instruction to be consumed @@ -159,7 +164,8 @@ class PlaylistDetailFragment : initialNavDestinationChange = true return } - // Drop any pending playlist edits when navigating away. + // Drop any pending playlist edits when navigating away. This could actually happen + // if the user is quick enough. detailModel.dropPlaylistEdit() } @@ -188,6 +194,10 @@ class PlaylistDetailFragment : musicModel.deletePlaylist(currentPlaylist) true } + R.id.action_save -> { + detailModel.savePlaylistEdit() + true + } else -> false } } @@ -214,21 +224,10 @@ class PlaylistDetailFragment : } override fun onStartEdit() { - selectionModel.drop() detailModel.startPlaylistEdit() } - override fun onConfirmEdit() { - detailModel.confirmPlaylistEdit() - } - - override fun onDropEdit() { - detailModel.dropPlaylistEdit() - } - - override fun onOpenSortMenu(anchor: View) { - throw IllegalStateException() - } + override fun onOpenSortMenu(anchor: View) {} private fun updatePlaylist(playlist: Playlist?) { if (playlist == null) { @@ -236,7 +235,9 @@ class PlaylistDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = playlist.name.resolve(requireContext()) + val binding = requireBinding() + binding.detailNormalToolbar.title = playlist.name.resolve(requireContext()) + binding.detailEditToolbar.title = "Editing ${playlist.name.resolve(requireContext())}" playlistHeaderAdapter.setParent(playlist) } @@ -279,18 +280,35 @@ class PlaylistDetailFragment : } private fun updateEditedPlaylist(editedPlaylist: List?) { - // TODO: Disable check item when no edits have been made - - // TODO: Massively improve how this UI is indicated: - // - Add an additional toolbar to indicate editing - // - Header should flip to re-sort button eventually - playlistListAdapter.setEditing(editedPlaylist != null) playlistHeaderAdapter.setEditedPlaylist(editedPlaylist) + selectionModel.drop() + + logD(editedPlaylist == detailModel.currentPlaylist.value?.songs) + requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).isEnabled = + editedPlaylist != detailModel.currentPlaylist.value?.songs + + updateMultiToolbar() } private fun updateSelection(selected: List) { playlistListAdapter.setSelected(selected.toSet()) - requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) + + 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/list/PlaylistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt index 75306da26..81fde8777 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -106,10 +106,6 @@ class PlaylistDetailListAdapter(private val listener: Listener) : interface Listener : DetailListAdapter.Listener, EditableListListener { /** Called when the "edit" option is selected in the edit header. */ fun onStartEdit() - /** Called when the "confirm" option is selected in the edit header. */ - fun onConfirmEdit() - /** Called when the "cancel" option is selected in the edit header. */ - fun onDropEdit() } /** @@ -169,13 +165,9 @@ private class EditHeaderViewHolder private constructor(private val binding: Item TooltipCompat.setTooltipText(this, contentDescription) setOnClickListener { listener.onStartEdit() } } - binding.headerConfirm.apply { + binding.headerSort.apply { TooltipCompat.setTooltipText(this, contentDescription) - setOnClickListener { listener.onConfirmEdit() } - } - binding.headerCancel.apply { - TooltipCompat.setTooltipText(this, contentDescription) - setOnClickListener { listener.onDropEdit() } + setOnClickListener(listener::onOpenSortMenu) } } @@ -184,12 +176,8 @@ private class EditHeaderViewHolder private constructor(private val binding: Item isGone = editing jumpDrawablesToCurrentState() } - binding.headerConfirm.apply { - isVisible = editing - jumpDrawablesToCurrentState() - } - binding.headerCancel.apply { - isVisible = editing + binding.headerSort.apply { + isGone = !editing jumpDrawablesToCurrentState() } } @@ -238,6 +226,7 @@ private constructor(private val binding: ItemEditableSongBinding) : elevation = binding.context.getDimen(R.dimen.elevation_normal) alpha = 0 } + init { binding.body.background = LayerDrawable( 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 87ece65f2..5f26f32d5 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -102,7 +102,7 @@ class HomeFragment : // --- UI SETUP --- binding.homeAppbar.addOnOffsetChangedListener(this) - binding.homeToolbar.apply { + binding.homeNormalToolbar.apply { setOnMenuItemClickListener(this@HomeFragment) MenuCompat.setGroupDividerEnabled(menu, true) } @@ -169,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) { @@ -178,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) } @@ -243,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. @@ -285,7 +284,7 @@ class HomeFragment : } val sortMenu = - unlikelyToBeNull(binding.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) { @@ -456,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) } } 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 6b1965c58..f81ed13fb 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 @@ -50,6 +50,7 @@ import okio.buffer import okio.source import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.ImageSettings +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.logD @@ -66,10 +67,10 @@ constructor( val albums = computeAlbumOrdering(songs) val streams = mutableListOf() for (album in albums) { + openInputStream(album)?.let(streams::add) if (streams.size == 4) { return createMosaic(streams, size) } - openInputStream(album)?.let(streams::add) } return streams.firstOrNull()?.let { stream -> @@ -81,7 +82,7 @@ constructor( } fun computeAlbumOrdering(songs: List): Collection = - songs.groupByTo(sortedMapOf(compareByDescending { it.songs.size })) { it.album }.keys + Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(songs.groupBy { it.album }.keys) private suspend fun openInputStream(album: Album): InputStream? = try { 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 bcba5195e..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 @@ -39,20 +39,13 @@ abstract class SelectionFragment : 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.drop() } + setNavigationOnClickListener { selectionModel.drop() } setOnMenuItemClickListener(this@SelectionFragment) } } 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 7db98bb97..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt +++ /dev/null @@ -1,178 +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) - * - * TODO: Generalize this into a "view flipper" class and then derive it through other means? - */ -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/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index b0a0feb06..71a80eb62 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -81,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 @@ -126,7 +126,7 @@ class SearchFragment : ListFragment() { override fun onDestroyBinding(binding: FragmentSearchBinding) { super.onDestroyBinding(binding) - binding.searchToolbar.setOnMenuItemClickListener(null) + binding.searchNormalToolbar.setOnMenuItemClickListener(null) binding.searchRecycler.adapter = null } @@ -198,10 +198,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/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/res/drawable/ic_save_24.xml b/app/src/main/res/drawable/ic_save_24.xml new file mode 100644 index 000000000..3761438c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_save_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/fragment_detail.xml b/app/src/main/res/layout/fragment_detail.xml index 82a5fc5fa..a272ca07f 100644 --- a/app/src/main/res/layout/fragment_detail.xml +++ b/app/src/main/res/layout/fragment_detail.xml @@ -13,19 +13,38 @@ app:liftOnScroll="true" app:liftOnScrollTargetViewId="@id/detail_recycler"> - - + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 8fb877122..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"> - - + + + - - + + + diff --git a/app/src/main/res/layout/item_edit_header.xml b/app/src/main/res/layout/item_edit_header.xml index 3b999323e..02d528635 100644 --- a/app/src/main/res/layout/item_edit_header.xml +++ b/app/src/main/res/layout/item_edit_header.xml @@ -29,22 +29,13 @@ app:layout_constraintEnd_toEndOf="parent" /> - - \ No newline at end of file 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 From 572b0e52f81ad0b7f1154c5e6214dcb3a95c25d4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 20 May 2023 11:23:16 -0600 Subject: [PATCH 74/88] music: clean up playlist experience Add a variety of mild fixes and qol improvements regarding playlists. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 28 ++++++++++--------- .../auxio/detail/PlaylistDetailFragment.kt | 11 ++++---- .../header/PlaylistDetailHeaderAdapter.kt | 1 + .../detail/list/PlaylistDetailListAdapter.kt | 3 +- .../auxio/home/list/PlaylistListFragment.kt | 7 +++++ .../auxio/list/adapter/FlexibleListAdapter.kt | 5 ++-- .../auxio/music/system/IndexerService.kt | 1 - .../auxio/playback/PlaybackPanelFragment.kt | 8 ++++++ .../org/oxycblt/auxio/playback/queue/Queue.kt | 2 +- .../playback/state/PlaybackStateManager.kt | 19 +++++++++---- .../main/res/drawable-v23/ui_item_ripple.xml | 3 +- app/src/main/res/drawable/ic_save_24.xml | 3 +- ...tem_ripple_bg.xml => sel_selection_bg.xml} | 0 app/src/main/res/drawable/ui_item_bg.xml | 5 ++++ app/src/main/res/drawable/ui_item_ripple.xml | 1 - app/src/main/res/layout/item_album_song.xml | 2 +- .../main/res/layout/item_editable_song.xml | 2 +- app/src/main/res/layout/item_parent.xml | 2 +- app/src/main/res/layout/item_song.xml | 2 +- app/src/main/res/menu/menu_playback.xml | 3 ++ .../res/menu/menu_playlist_song_actions.xml | 18 ++++++++++++ 21 files changed, 89 insertions(+), 37 deletions(-) rename app/src/main/res/drawable/{sel_item_ripple_bg.xml => sel_selection_bg.xml} (100%) create mode 100644 app/src/main/res/drawable/ui_item_bg.xml create mode 100644 app/src/main/res/menu/menu_playlist_song_actions.xml 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 367ef3545..e62859543 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -348,7 +348,6 @@ constructor( * @param at The position of the item to remove, in the list adapter data. */ fun removePlaylistSong(at: Int) { - // TODO: Remove header when empty val playlist = _currentPlaylist.value ?: return val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList() val realAt = at - 2 @@ -357,7 +356,13 @@ constructor( } editedPlaylist.removeAt(realAt) _editedPlaylist.value = editedPlaylist - refreshPlaylistList(playlist, UpdateInstructions.Remove(at)) + refreshPlaylistList( + playlist, + if (editedPlaylist.isNotEmpty()) { + UpdateInstructions.Remove(at, 1) + } else { + UpdateInstructions.Remove(at - 2, 3) + }) } private fun refreshAudioInfo(song: Song) { @@ -490,18 +495,15 @@ constructor( logD("Refreshing playlist list") val list = mutableListOf() - val newInstructions = - if (playlist.songs.isNotEmpty()) { - val header = EditHeader(R.string.lbl_songs) - list.add(Divider(header)) - list.add(header) - list.addAll(_editedPlaylist.value ?: playlist.songs) - instructions - } else { - UpdateInstructions.Diff - } + 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(newInstructions) + _playlistInstructions.put(instructions) _playlistList.value = list } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 4b8c2650c..4bd406ed0 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -211,8 +211,7 @@ class PlaylistDetailFragment : } override fun onOpenMenu(item: Song, anchor: View) { - // TODO: Remove "Add to playlist" option, makes no sense - openMusicMenu(anchor, R.menu.menu_song_actions, item) + openMusicMenu(anchor, R.menu.menu_playlist_song_actions, item) } override fun onPlay() { @@ -284,9 +283,11 @@ class PlaylistDetailFragment : playlistHeaderAdapter.setEditedPlaylist(editedPlaylist) selectionModel.drop() - logD(editedPlaylist == detailModel.currentPlaylist.value?.songs) - requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).isEnabled = - editedPlaylist != detailModel.currentPlaylist.value?.songs + if (editedPlaylist != null) { + requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply { + isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs + } + } updateMultiToolbar() } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt index 375278a39..08ce66ba4 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/PlaylistDetailHeaderAdapter.kt @@ -83,6 +83,7 @@ private constructor(private val binding: ItemDetailHeaderBinding) : 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) 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 index 81fde8777..69e0509d5 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -99,7 +99,7 @@ class PlaylistDetailListAdapter(private val listener: Listener) : return } this.isEditing = editing - notifyItemRangeChanged(1, currentList.size - 2, PAYLOAD_EDITING_CHANGED) + notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED) } /** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */ @@ -256,6 +256,7 @@ private constructor(private val binding: ItemEditableSongBinding) : } override fun updateSelectionIndicator(isSelected: Boolean) { + binding.interactBody.isActivated = isSelected binding.songAlbumCover.isActivated = isSelected } 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 index a41abdd1d..4c5d8d19a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -44,6 +44,13 @@ 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, 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 c76ffaae6..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 @@ -93,8 +93,9 @@ sealed interface 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/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 557dda7c4..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 @@ -131,7 +131,6 @@ class IndexerService : override val scope = indexScope override fun onMusicChanges(changes: MusicRepository.Changes) { - // TODO: Do not pause when playlist changes val deviceLibrary = musicRepository.deviceLibrary ?: return // Wipe possibly-invalidated outdated covers imageLoader.memoryCache?.clear() 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 636e2e2ca..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,6 +34,7 @@ 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 @@ -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 @@ -164,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( 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/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/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_save_24.xml b/app/src/main/res/drawable/ic_save_24.xml index 3761438c0..4fc73a9f3 100644 --- a/app/src/main/res/drawable/ic_save_24.xml +++ b/app/src/main/res/drawable/ic_save_24.xml @@ -4,8 +4,9 @@ android:height="24dp" android:viewportWidth="960" android:viewportHeight="960" - android:tint="?attr/colorControlNormal"> + android:tint="@color/sel_activatable_icon"> + 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/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_editable_song.xml b/app/src/main/res/layout/item_editable_song.xml index 9cfa194eb..93fe6f0de 100644 --- a/app/src/main/res/layout/item_editable_song.xml +++ b/app/src/main/res/layout/item_editable_song.xml @@ -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"> + + + + + + + + \ No newline at end of file From 0597fa876cb0287dc5e849b32fb10c23fb81ea0b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 20 May 2023 11:28:14 -0600 Subject: [PATCH 75/88] detail: drop playlist resorting for now Don't really have the UI for it currently. It would require some kind of dialog instead of a popup menu, which is planned eventually. --- .../detail/list/PlaylistDetailListAdapter.kt | 20 ++++++------------- app/src/main/res/layout/item_edit_header.xml | 18 ++++++++--------- 2 files changed, 15 insertions(+), 23 deletions(-) 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 index 69e0509d5..47737f7f7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -24,7 +24,6 @@ import android.view.View import android.view.ViewGroup import androidx.annotation.StringRes import androidx.appcompat.widget.TooltipCompat -import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView @@ -148,7 +147,11 @@ class PlaylistDetailListAdapter(private val listener: Listener) : */ data class EditHeader(@StringRes override val titleRes: Int) : Header -/** Displays an [EditHeader] and it's actions. Use [from] to create an instance. */ +/** + * 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 { /** @@ -165,21 +168,10 @@ private class EditHeaderViewHolder private constructor(private val binding: Item TooltipCompat.setTooltipText(this, contentDescription) setOnClickListener { listener.onStartEdit() } } - binding.headerSort.apply { - TooltipCompat.setTooltipText(this, contentDescription) - setOnClickListener(listener::onOpenSortMenu) - } } override fun updateEditing(editing: Boolean) { - binding.headerEdit.apply { - isGone = editing - jumpDrawablesToCurrentState() - } - binding.headerSort.apply { - isGone = !editing - jumpDrawablesToCurrentState() - } + binding.headerEdit.isEnabled = !editing } companion object { diff --git a/app/src/main/res/layout/item_edit_header.xml b/app/src/main/res/layout/item_edit_header.xml index 02d528635..80659deca 100644 --- a/app/src/main/res/layout/item_edit_header.xml +++ b/app/src/main/res/layout/item_edit_header.xml @@ -28,14 +28,14 @@ app:icon="@drawable/ic_edit_24" app:layout_constraintEnd_toEndOf="parent" /> - + + + + + + + + + \ No newline at end of file From c86970470f9751f0f3fb4ce85541cc4570b207c0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 20 May 2023 15:01:46 -0600 Subject: [PATCH 76/88] music: back playlists with database Finally persist playlists with a backing database. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 9 +- .../oxycblt/auxio/music/MusicRepository.kt | 20 ++-- .../org/oxycblt/auxio/music/MusicViewModel.kt | 11 +- .../oxycblt/auxio/music/user/RawPlaylist.kt | 22 +++- .../oxycblt/auxio/music/user/UserLibrary.kt | 70 +++++++----- .../oxycblt/auxio/music/user/UserModule.kt | 2 +- .../auxio/music/user/UserMusicDatabase.kt | 100 +++++++++++++++++- 7 files changed, 186 insertions(+), 48 deletions(-) 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 e62859543..a1682ffb7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -299,8 +299,13 @@ constructor( */ fun savePlaylistEdit() { val playlist = _currentPlaylist.value ?: return - val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null } - musicRepository.rewritePlaylist(playlist, editedPlaylist) + 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 + } } /** 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 8d7c64f7b..df5e011a3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -116,7 +116,7 @@ interface MusicRepository { * @param name The name of the new [Playlist]. * @param songs The songs to populate the new [Playlist] with. */ - fun createPlaylist(name: String, songs: List) + suspend fun createPlaylist(name: String, songs: List) /** * Rename a [Playlist]. @@ -124,14 +124,14 @@ interface MusicRepository { * @param playlist The [Playlist] to rename. * @param name The name of the new [Playlist]. */ - fun renamePlaylist(playlist: Playlist, name: String) + suspend fun renamePlaylist(playlist: Playlist, name: String) /** * Delete a [Playlist]. * * @param playlist The playlist to delete. */ - fun deletePlaylist(playlist: Playlist) + suspend fun deletePlaylist(playlist: Playlist) /** * Add the given [Song]s to a [Playlist]. @@ -139,7 +139,7 @@ interface MusicRepository { * @param songs The [Song]s to add to the [Playlist]. * @param playlist The [Playlist] to add to. */ - fun addToPlaylist(songs: List, playlist: Playlist) + suspend fun addToPlaylist(songs: List, playlist: Playlist) /** * Update the [Song]s of a [Playlist]. @@ -147,7 +147,7 @@ interface MusicRepository { * @param playlist The [Playlist] to update. * @param songs The new [Song]s to be contained in the [Playlist]. */ - fun rewritePlaylist(playlist: Playlist, songs: List) + suspend fun rewritePlaylist(playlist: Playlist, songs: List) /** * Request that a music loading operation is started by the current [IndexingWorker]. Does @@ -276,7 +276,7 @@ constructor( (deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) } ?: userLibrary?.findPlaylist(uid)) - override fun createPlaylist(name: String, songs: List) { + override suspend fun createPlaylist(name: String, songs: List) { val userLibrary = userLibrary ?: return userLibrary.createPlaylist(name, songs) for (listener in updateListeners) { @@ -285,7 +285,7 @@ constructor( } } - override fun renamePlaylist(playlist: Playlist, name: String) { + override suspend fun renamePlaylist(playlist: Playlist, name: String) { val userLibrary = userLibrary ?: return userLibrary.renamePlaylist(playlist, name) for (listener in updateListeners) { @@ -294,7 +294,7 @@ constructor( } } - override fun deletePlaylist(playlist: Playlist) { + override suspend fun deletePlaylist(playlist: Playlist) { val userLibrary = userLibrary ?: return userLibrary.deletePlaylist(playlist) for (listener in updateListeners) { @@ -303,7 +303,7 @@ constructor( } } - override fun addToPlaylist(songs: List, playlist: Playlist) { + override suspend fun addToPlaylist(songs: List, playlist: Playlist) { val userLibrary = userLibrary ?: return userLibrary.addToPlaylist(playlist, songs) for (listener in updateListeners) { @@ -312,7 +312,7 @@ constructor( } } - override fun rewritePlaylist(playlist: Playlist, songs: List) { + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { val userLibrary = userLibrary ?: return userLibrary.rewritePlaylist(playlist, songs) for (listener in updateListeners) { 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 873ed851e..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,10 +19,13 @@ 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 kotlinx.coroutines.launch import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent @@ -110,7 +113,7 @@ constructor( */ fun createPlaylist(name: String? = null, songs: List = listOf()) { if (name != null) { - musicRepository.createPlaylist(name, songs) + viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) } } else { _newPlaylistSongs.put(songs) } @@ -124,7 +127,7 @@ constructor( */ fun renamePlaylist(playlist: Playlist, name: String? = null) { if (name != null) { - musicRepository.renamePlaylist(playlist, name) + viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) } } else { _playlistToRename.put(playlist) } @@ -139,7 +142,7 @@ constructor( */ fun deletePlaylist(playlist: Playlist, rude: Boolean = false) { if (rude) { - musicRepository.deletePlaylist(playlist) + viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) } } else { _playlistToDelete.put(playlist) } @@ -193,7 +196,7 @@ constructor( */ fun addToPlaylist(songs: List, playlist: Playlist? = null) { if (playlist != null) { - musicRepository.addToPlaylist(songs, playlist) + viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) } } else { _songsToAdd.put(songs) } 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 index 6f56be360..51c15d1bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt @@ -21,6 +21,10 @@ 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( @@ -30,12 +34,26 @@ data class RawPlaylist( 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) -@Entity(primaryKeys = ["playlistUid", "songUid"]) + +/** + * Links individual songs to a playlist entry. + * @author Alexander Capehart (OxygenCobalt) + */ +@Entity data class PlaylistSongCrossRef( + @PrimaryKey(autoGenerate = true) val id: Long = 0, val playlistUid: Music.UID, - @ColumnInfo(index = true) val songUid: Music.UID + 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 index 7962533de..fc64f5918 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -57,11 +57,12 @@ interface UserLibrary { /** * Create a new [UserLibrary]. * - * @param deviceLibrary Asynchronously populated [DeviceLibrary] that can be obtained later. - * This allows database information to be read before the actual instance is constructed. + * @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(deviceLibrary: Channel): MutableUserLibrary + suspend fun read(deviceLibraryChannel: Channel): MutableUserLibrary } } @@ -78,7 +79,7 @@ interface MutableUserLibrary : UserLibrary { * @param name The name of the [Playlist]. * @param songs The songs to place in the [Playlist]. */ - fun createPlaylist(name: String, songs: List) + suspend fun createPlaylist(name: String, songs: List) /** * Rename a [Playlist]. @@ -86,21 +87,21 @@ interface MutableUserLibrary : UserLibrary { * @param playlist The [Playlist] to rename. * @param name The name of the new [Playlist]. */ - fun renamePlaylist(playlist: Playlist, name: String) + suspend fun renamePlaylist(playlist: Playlist, name: String) /** * Delete a [Playlist]. * * @param playlist The playlist to delete. */ - fun deletePlaylist(playlist: Playlist) + suspend fun deletePlaylist(playlist: Playlist) /** * Add [Song]s to a [Playlist]. * * @param playlist The [Playlist] to add to. Must currently exist. */ - fun addToPlaylist(playlist: Playlist, songs: List) + suspend fun addToPlaylist(playlist: Playlist, songs: List) /** * Update the [Song]s of a [Playlist]. @@ -108,23 +109,32 @@ interface MutableUserLibrary : UserLibrary { * @param playlist The [Playlist] to update. * @param songs The new [Song]s to be contained in the [Playlist]. */ - fun rewritePlaylist(playlist: Playlist, songs: List) + 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(deviceLibrary: Channel): MutableUserLibrary = - UserLibraryImpl(playlistDao, deviceLibrary.receive(), musicSettings) + 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 deviceLibrary: DeviceLibrary, + private val playlistMap: MutableMap, private val musicSettings: MusicSettings ) : MutableUserLibrary { - private val playlistMap = mutableMapOf() override val playlists: List get() = playlistMap.values.toList() @@ -132,35 +142,41 @@ private class UserLibraryImpl( override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } - @Synchronized - override fun createPlaylist(name: String, songs: List) { + override suspend fun createPlaylist(name: String, songs: List) { val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) - playlistMap[playlistImpl.uid] = playlistImpl + synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } + val rawPlaylist = + RawPlaylist( + PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw), + playlistImpl.songs.map { PlaylistSong(it.uid) }) + playlistDao.insertPlaylist(rawPlaylist) } - @Synchronized - override fun renamePlaylist(playlist: Playlist, name: String) { + override suspend fun renamePlaylist(playlist: Playlist, name: String) { val playlistImpl = requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } - playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) + synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) } + playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name)) } - @Synchronized - override fun deletePlaylist(playlist: Playlist) { - requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" } + override suspend fun deletePlaylist(playlist: Playlist) { + synchronized(this) { + requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" } + } + playlistDao.deletePlaylist(playlist.uid) } - @Synchronized - override fun addToPlaylist(playlist: Playlist, songs: List) { + override suspend fun addToPlaylist(playlist: Playlist, songs: List) { val playlistImpl = requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } - playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } + synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } } + playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) } - @Synchronized - override fun rewritePlaylist(playlist: Playlist, songs: List) { + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { val playlistImpl = requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" } - playlistMap[playlist.uid] = playlistImpl.edit(songs) + 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 index 618babd4d..10e55c5bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt @@ -30,7 +30,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) interface UserModule { - @Binds fun userLibaryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory + @Binds fun userLibraryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory } @Module 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 index 361d4f85f..d356d9721 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt @@ -21,16 +21,112 @@ 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 = 28, + 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 { - @Transaction @Query("SELECT * FROM PlaylistInfo") fun readRawPlaylists(): List + /** + * 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 [Song]s of the current 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) } From 5244a2b8582ce070fb4a76311f8907aaf017e4a4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 20 May 2023 20:11:33 -0600 Subject: [PATCH 77/88] build: fix release Apparently AGP throws a fit when you don't suppress warnings for cryptography classes that aren't even used. Great. --- CHANGELOG.md | 3 +++ app/proguard-rules.pro | 13 ++++++++++++- .../org/oxycblt/auxio/music/user/RawPlaylist.kt | 5 ++++- .../oxycblt/auxio/music/user/UserMusicDatabase.kt | 11 ++++++++++- .../org/oxycblt/auxio/music/FakeMusicRepository.kt | 10 +++++----- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a774917..b40b82461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## dev +#### What's New +- **Playlists.** The long-awaited feature has arrived, with more functionality coming soon. + #### What's Improved - Sorting now handles numbers of arbitrary length - Punctuation is now ignored in sorting with intelligent sort names disabled 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/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt index 51c15d1bd..1befba8aa 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt @@ -23,6 +23,7 @@ import org.oxycblt.auxio.music.Music /** * Raw playlist information persisted to [UserMusicDatabase]. + * * @author Alexander Capehart (OxygenCobalt) */ data class RawPlaylist( @@ -36,19 +37,21 @@ data class RawPlaylist( /** * 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 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 index d356d9721..ed790640a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt @@ -23,6 +23,7 @@ import org.oxycblt.auxio.music.Music /** * Allows persistence of all user-created music information. + * * @author Alexander Capehart (OxygenCobalt) */ @Database( @@ -39,12 +40,14 @@ abstract class UserMusicDatabase : RoomDatabase() { /** * 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 @@ -53,6 +56,7 @@ interface PlaylistDao { /** * Create a new playlist. + * * @param rawPlaylist The [RawPlaylist] to create. */ @Transaction @@ -68,6 +72,7 @@ interface PlaylistDao { /** * Replace the currently-stored [PlaylistInfo] for a playlist entry. + * * @param playlistInfo The new [PlaylistInfo] to store. */ @Transaction @@ -78,6 +83,7 @@ interface PlaylistDao { /** * Delete a playlist entry's [PlaylistInfo] and [PlaylistSong]. + * * @param playlistUid The [Music.UID] of the playlist to delete. */ @Transaction @@ -88,6 +94,7 @@ interface PlaylistDao { /** * 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. */ @@ -100,8 +107,10 @@ interface PlaylistDao { /** * Replace the currently-stored [Song]s of the current 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. + * @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) { diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt index 735f2fc02..4af3e64b3 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt @@ -58,23 +58,23 @@ open class FakeMusicRepository : MusicRepository { throw NotImplementedError() } - override fun createPlaylist(name: String, songs: List) { + override suspend fun createPlaylist(name: String, songs: List) { throw NotImplementedError() } - override fun renamePlaylist(playlist: Playlist, name: String) { + override suspend fun renamePlaylist(playlist: Playlist, name: String) { throw NotImplementedError() } - override fun deletePlaylist(playlist: Playlist) { + override suspend fun deletePlaylist(playlist: Playlist) { throw NotImplementedError() } - override fun addToPlaylist(songs: List, playlist: Playlist) { + override suspend fun addToPlaylist(songs: List, playlist: Playlist) { throw NotImplementedError() } - override fun rewritePlaylist(playlist: Playlist, songs: List) { + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { throw NotImplementedError() } From 049d2bc152c77d2fb020b26299c9161d06b7cf10 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 20 May 2023 20:14:00 -0600 Subject: [PATCH 78/88] build: update deps --- app/build.gradle | 4 ++-- build.gradle | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6586384d1..7c1fdb148 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -77,7 +77,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - def coroutines_version = "1.7.0" + def coroutines_version = '1.7.1' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version" @@ -141,7 +141,7 @@ dependencies { 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/build.gradle b/build.gradle index 27c3ff77c..754d9b9a6 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { kotlin_version = '1.8.21' navigation_version = "2.5.3" - hilt_version = '2.46' + hilt_version = '2.46.1' } repositories { From 8953f12a1e4912ef2bbd68067c2684e97b97e636 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 21 May 2023 09:53:07 -0600 Subject: [PATCH 79/88] music: try to fix repo race conditions Forgot to slather the entire class in Synchronized and Volatile. Should make crashes less likely, I hope. --- .../java/org/oxycblt/auxio/MainActivity.kt | 1 + .../java/org/oxycblt/auxio/home/tabs/Tab.kt | 2 + .../oxycblt/auxio/music/MusicRepository.kt | 52 +++++++++---------- 3 files changed, 27 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 6d9bc9e9b..9d87a0c68 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -51,6 +51,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * TODO: Fix UID naming * TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims) * TODO: Add more logging + * TODO: Try to move on from shared objs in synchronized and volatile */ @AndroidEntryPoint class MainActivity : AppCompatActivity() { 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 30425e6d8..cd7bf8f06 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 @@ -26,6 +26,8 @@ import org.oxycblt.auxio.util.logE * * @param mode The type of list in the home view this instance corresponds to. * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Tab migration to playlists is busted and resets the config entirely. Need to fix. */ sealed class Tab(open val mode: MusicMode) { /** 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 df5e011a3..6fa0b4f79 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -219,12 +219,12 @@ constructor( ) : MusicRepository { private val updateListeners = mutableListOf() private val indexingListeners = mutableListOf() - private var indexingWorker: MusicRepository.IndexingWorker? = null + @Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null - override var deviceLibrary: DeviceLibrary? = null - override var userLibrary: MutableUserLibrary? = null - private var previousCompletedState: IndexingState.Completed? = null - private var currentIndexingState: IndexingState? = null + @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 @@ -272,55 +272,50 @@ constructor( 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 = userLibrary ?: return + val userLibrary = synchronized(this) { userLibrary ?: return } userLibrary.createPlaylist(name, songs) - for (listener in updateListeners) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) - } + notifyUserLibraryChange() } override suspend fun renamePlaylist(playlist: Playlist, name: String) { - val userLibrary = userLibrary ?: return + val userLibrary = synchronized(this) { userLibrary ?: return } userLibrary.renamePlaylist(playlist, name) - for (listener in updateListeners) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) - } + notifyUserLibraryChange() } override suspend fun deletePlaylist(playlist: Playlist) { - val userLibrary = userLibrary ?: return + val userLibrary = synchronized(this) { userLibrary ?: return } userLibrary.deletePlaylist(playlist) - for (listener in updateListeners) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) - } + notifyUserLibraryChange() } override suspend fun addToPlaylist(songs: List, playlist: Playlist) { - val userLibrary = userLibrary ?: return + val userLibrary = synchronized(this) { userLibrary ?: return } userLibrary.addToPlaylist(playlist, songs) - for (listener in updateListeners) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) - } + notifyUserLibraryChange() } override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { - val userLibrary = userLibrary ?: return + 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) } @@ -400,9 +395,10 @@ constructor( throw NoMusicException() } - // Successfully loaded the library, now save the cache and create the library in - // parallel. + // 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 = From fb892453bde3256b395f437f4d28e29dee55dfba Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 21 May 2023 11:45:00 -0600 Subject: [PATCH 80/88] home: fix tab setting migration Fix a few issues with the tab migration: 1. It wasn't even being ran 2. It incorrectly updated the tabs by adding a playlist tab when it was actually already present. --- app/src/main/java/org/oxycblt/auxio/Auxio.kt | 3 +++ app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt | 9 ++++++--- app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt | 2 -- 3 files changed, 9 insertions(+), 5 deletions(-) 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/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt index 53fa86faa..60d3144e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt @@ -71,10 +71,13 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context) Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT)) ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT)) - // Add the new playlist tab to old tab configurations - val correctedTabs = oldTabs + Tab.Visible(MusicMode.PLAYLISTS) + // 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(correctedTabs)) + putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs)) remove(OLD_KEY_LIB_TABS) } } 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 cd7bf8f06..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 @@ -26,8 +26,6 @@ import org.oxycblt.auxio.util.logE * * @param mode The type of list in the home view this instance corresponds to. * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Tab migration to playlists is busted and resets the config entirely. Need to fix. */ sealed class Tab(open val mode: MusicMode) { /** From 89eeaa33cc68b411b7abba17c995c3eb1718dbca Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 21 May 2023 12:00:21 -0600 Subject: [PATCH 81/88] list: avoid crashing on span size lookups Apparently sometimes the span size lookup will try to find an item that does not exist. Fix that. --- app/src/main/java/org/oxycblt/auxio/MainActivity.kt | 2 +- .../java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt | 5 ++++- .../java/org/oxycblt/auxio/detail/GenreDetailFragment.kt | 5 ++++- .../java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt | 5 ++++- app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt | 5 ++++- app/src/main/res/values/strings.xml | 1 + 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 9d87a0c68..d29b513a6 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -51,7 +51,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * TODO: Fix UID naming * TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims) * TODO: Add more logging - * TODO: Try to move on from shared objs in synchronized and volatile + * 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/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 619a48211..4677aee62 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -101,7 +101,10 @@ class ArtistDetailFragment : adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter) (layoutManager as GridLayoutManager).setFullWidthLookup { if (it != 0) { - val item = detailModel.artistList.value[it - 1] + val item = + detailModel.artistList.value.getOrElse(it - 1) { + return@setFullWidthLookup false + } item is Divider || item is Header } else { true 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 862d5d2ef..4ef67d581 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -94,7 +94,10 @@ class GenreDetailFragment : adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter) (layoutManager as GridLayoutManager).setFullWidthLookup { if (it != 0) { - val item = detailModel.genreList.value[it - 1] + val item = + detailModel.genreList.value.getOrElse(it - 1) { + return@setFullWidthLookup false + } item is Divider || item is Header } else { true diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 4bd406ed0..14ded5fa3 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -109,7 +109,10 @@ class PlaylistDetailFragment : } (layoutManager as GridLayoutManager).setFullWidthLookup { if (it != 0) { - val item = detailModel.playlistList.value[it - 1] + val item = + detailModel.playlistList.value.getOrElse(it - 1) { + return@setFullWidthLookup false + } item is Divider || item is Header } else { true 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 71a80eb62..a7b29b204 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -110,7 +110,10 @@ class SearchFragment : ListFragment() { binding.searchRecycler.apply { adapter = searchAdapter (layoutManager as GridLayoutManager).setFullWidthLookup { - val item = searchModel.searchResults.value[it] + val item = + searchModel.searchResults.value.getOrElse(it) { + return@setFullWidthLookup false + } item is Divider || item is Header } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ed57f931..0eca031c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -335,6 +335,7 @@ No track No songs No music playing + There\'s nothing here yet From b764796500d7a107c11ca3235dcac93e836d7d86 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 21 May 2023 21:12:36 +0200 Subject: [PATCH 82/88] Translations update from Hosted Weblate (#441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Spanish) Currently translated at 100.0% (263 of 263 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (263 of 263 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (267 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Czech) Currently translated at 100.0% (267 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (267 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (267 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Italian) Currently translated at 100.0% (267 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/ * Translated using Weblate (Korean) Currently translated at 100.0% (267 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Polish) Currently translated at 100.0% (267 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/ * Translated using Weblate (Russian) Currently translated at 100.0% (267 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Japanese) Currently translated at 100.0% (267 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ja/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (267 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Serbian) Currently translated at 2.2% (6 of 267 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sr/ * Translated using Weblate (Czech) Currently translated at 100.0% (268 of 268 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Czech) Currently translated at 100.0% (270 of 270 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (German) Currently translated at 100.0% (270 of 270 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (270 of 270 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (270 of 270 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (274 of 274 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (274 of 274 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Czech) Currently translated at 100.0% (274 of 274 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (German) Currently translated at 100.0% (274 of 274 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Spanish) Currently translated at 100.0% (274 of 274 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (274 of 274 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Spanish) Currently translated at 100.0% (275 of 275 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (275 of 275 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (275 of 275 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Croatian) Currently translated at 100.0% (275 of 275 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/ --------- Co-authored-by: gallegonovato Co-authored-by: Eric Co-authored-by: Fjuro Co-authored-by: Skrripy Co-authored-by: Макар Разин Co-authored-by: Maciej Klupp Co-authored-by: Ettore Atalan Co-authored-by: BMT[UA] Co-authored-by: Milo Ivir --- app/src/main/res/values-be/strings.xml | 4 ++++ app/src/main/res/values-cs/strings.xml | 13 +++++++++++++ app/src/main/res/values-de/strings.xml | 15 ++++++++++++++- app/src/main/res/values-es/strings.xml | 14 ++++++++++++++ app/src/main/res/values-hr/strings.xml | 16 +++++++++++++++- app/src/main/res/values-it/strings.xml | 9 ++++++++- app/src/main/res/values-ja/strings.xml | 9 ++++++++- app/src/main/res/values-ko/strings.xml | 8 +++++++- app/src/main/res/values-pl/strings.xml | 6 ++++++ app/src/main/res/values-ru/strings.xml | 4 ++++ app/src/main/res/values-sr/strings.xml | 9 ++++++++- app/src/main/res/values-uk/strings.xml | 16 ++++++++++++++-- app/src/main/res/values-zh-rCN/strings.xml | 14 ++++++++++++++ 13 files changed, 129 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index ce5bc96c2..c8701ec78 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -278,4 +278,8 @@ Стварыце новы плэйліст Плэйліст %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 4fd532ca5..912c15502 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -287,4 +287,17 @@ Ignorovat slova jako „the“ při řazení podle názvu (funguje nejlépe u hudby v angličtině) Žádné 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 d6e98eed5..e52fc3132 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -271,11 +271,24 @@ Persistenz Lautstärkeanpassung ReplayGain Absteigend - Playlist-Bild für %s + Wiedergabelistenbild für %s Wiedergabeliste Wiedergabelisten Artikel beim Sortieren ignorieren Wörter wie „the“ ignorieren (funktioniert am besten mit englischsprachiger Musik) Keine 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-es/strings.xml b/app/src/main/res/values-es/strings.xml index 99cfeb71b..adcafbaf7 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -282,4 +282,18 @@ 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-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index b724693bd..698bb0aa4 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -214,7 +214,7 @@ Zarez (,) Ampersand (&) Kompilacija uživo - Kompilacije remiksa + Kompilacija remiksa Kompilacije Znakovi odjeljivanja vrijednosti Prekini reprodukciju @@ -273,4 +273,18 @@ 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-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7a569af56..d9fa1243e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -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à @@ -281,4 +281,11 @@ 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 + Nessuno \ 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 1a9610f13..3670dcba1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -43,7 +43,7 @@ 前の曲にスキップ前に曲を巻き戻す 音楽フォルダ プラス (+) - リミックスオムニバス + リミックスコンピレーション DJミックス DJミックス ディスク @@ -266,4 +266,11 @@ プレイリスト %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 e1746f7cd..fd54b0717 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -174,7 +174,7 @@ %d Hz 믹스 라이브 컴필레이션 - 리믹스 컴필레이션 + 리믹스 편집 믹스 이퀄라이저 셔플 @@ -278,4 +278,10 @@ 이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함) 없음 새 재생 목록 만들기 + 새 재생목록 + 재생목록에 추가 + 생성된 재생목록 + 재생목록에 추가됨 + 재생목록 %d + 노래 없음 \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 55c1e2434..5754e7e38 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -283,4 +283,10 @@ Ignoruj słowa takie jak „the” oraz numery w tytule podczas sortowania (działa najlepiej z utworami w języku angielskim) Brak 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-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 39ff8b565..072da2d2d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -287,4 +287,8 @@ Создать новый плейлист Новый плейлист Плейлист %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 3a0906840..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-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c618eb6f9..c7f387bc4 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -218,8 +218,8 @@ Невідомий жанр Відкрити чергу Жовтий - Перемістити пісню в черзі - Видалити пісню з черги + Перемістити пісню + Видалити пісню Блакитний Зеленувато-блакитний Фіолетовий @@ -284,4 +284,16 @@ Створити новий список відтворення Новий список відтворення Список відтворення %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 ff969df42..1020ae1eb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -276,4 +276,18 @@ 排序时忽略冠词 按名称排序时忽略类似“the”这样的冠词(对英文歌曲的效果最好) 创建新的播放列表 + 新建播放列表 + 播放列表 %d + 已创建播放列表 + 添加到播放列表 + 已添加到播放列表 + 无歌曲 + 删除 + 删除播放列表? + 删除 %s 吗?此操作无法撤销。 + 重命名 + 重命名播放列表 + 已重命名播放列表 + 已删除播放列表 + 编辑 \ No newline at end of file From 877d380fa0e81cc0e50071c584786f2621185ed1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 21 May 2023 13:38:24 -0600 Subject: [PATCH 83/88] music: use indices in playlist db Use indices in the playlist database, which should improve perofrmance a little. --- .../main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt | 4 ++-- .../oxycblt/auxio/playback/persist/PersistenceDatabase.kt | 1 + app/src/main/res/drawable/ic_playing_indicator_24.xml | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) 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 index 1befba8aa..96d3b5e77 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt @@ -57,6 +57,6 @@ data class RawPlaylist( @Entity data class PlaylistSongCrossRef( @PrimaryKey(autoGenerate = true) val id: Long = 0, - val playlistUid: Music.UID, - val songUid: Music.UID + @ColumnInfo(index = true) val playlistUid: Music.UID, + @ColumnInfo(index = true) val songUid: Music.UID ) 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 8c6e59a6d..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 @@ -126,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/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"> From 9b379750082c4874771ebba6e5541e7b7e9749e8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 21 May 2023 14:45:45 -0600 Subject: [PATCH 84/88] image: fix album ordering Prior ordering sorted by album song count, which skewed results if an entire album was not present in an input list. --- .../java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt | 5 ++--- .../org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) 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 f81ed13fb..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 @@ -50,7 +50,6 @@ import okio.buffer import okio.source import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.ImageSettings -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.logD @@ -81,8 +80,8 @@ constructor( } } - fun computeAlbumOrdering(songs: List): Collection = - Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(songs.groupBy { it.album }.keys) + fun computeAlbumOrdering(songs: List) = + songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key } private suspend fun openInputStream(album: Album): InputStream? = try { 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 index d3fb58323..f13951212 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt @@ -52,6 +52,7 @@ class RenamePlaylistDialog : ViewBindingDialogFragment val playlist = unlikelyToBeNull(pickerModel.currentPlaylistToRename.value) From 1e9604be54b3720449e213c8248f09714874756a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 21 May 2023 19:42:58 -0600 Subject: [PATCH 85/88] build: bump to 3.1.0 Bump Auxio to version 3.1.0. --- CHANGELOG.md | 2 +- README.md | 11 ++++++----- app/build.gradle | 4 ++-- .../metadata/android/en-US/changelogs/30.txt | 2 ++ .../android/en-US/full_description.txt | 7 ++++--- .../en-US/images/phoneScreenshots/shot0.png | Bin 193350 -> 184917 bytes .../en-US/images/phoneScreenshots/shot1.png | Bin 62099 -> 99092 bytes .../en-US/images/phoneScreenshots/shot2.png | Bin 157647 -> 148481 bytes .../en-US/images/phoneScreenshots/shot3.png | Bin 137473 -> 117103 bytes .../en-US/images/phoneScreenshots/shot4.png | Bin 307636 -> 321362 bytes .../en-US/images/phoneScreenshots/shot5.png | Bin 91036 -> 83876 bytes .../en-US/images/phoneScreenshots/shot6.png | Bin 141903 -> 135637 bytes .../en-US/images/phoneScreenshots/shot7.png | Bin 167578 -> 156046 bytes 13 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/30.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index b40b82461..6eba0d742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## dev +## 3.1.0 #### What's New - **Playlists.** The long-awaited feature has arrived, with more functionality coming soon. 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 7c1fdb148..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 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..44348f759 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/30.txt @@ -0,0 +1,2 @@ +Auxio 3.1.0 introduces playlisting functionality for the first time, with more functionality 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 9ec7120a7b7503eebc0ea996f64472ff74f175c4..6067c95ae7783cc37a80ddad813dd65991f44238 100644 GIT binary patch literal 184917 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

`tJN$xlSS>Ap!|0 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 literal 193350 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 ydTd{aoFnj=6oQylzzE&1jA+ozufC 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

prppeO7emD9hMoxC%6RRK+PEH`_$1Jmu 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

Ft^>po=vfWRhBjH>*+>l~b5rJN+OcY71 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¬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