From a153a41f8ded385b6fd194f5fa7f65b367c3f533 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 18 May 2023 18:11:00 -0600 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 10/15] 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 11/15] 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 12/15] 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 13/15] 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 14/15] 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 15/15] 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