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/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/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/Auxio.kt b/app/src/main/java/org/oxycblt/auxio/Auxio.kt index 77eed0ff9..df737e4c2 100644 --- a/app/src/main/java/org/oxycblt/auxio/Auxio.kt +++ b/app/src/main/java/org/oxycblt/auxio/Auxio.kt @@ -25,6 +25,7 @@ import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject +import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.ui.UISettings @@ -39,6 +40,7 @@ class Auxio : Application() { @Inject lateinit var imageSettings: ImageSettings @Inject lateinit var playbackSettings: PlaybackSettings @Inject lateinit var uiSettings: UISettings + @Inject lateinit var homeSettings: HomeSettings override fun onCreate() { super.onCreate() @@ -46,6 +48,7 @@ class Auxio : Application() { imageSettings.migrate() playbackSettings.migrate() uiSettings.migrate() + homeSettings.migrate() // Adding static shortcuts in a dynamic manner is better than declaring them // manually, as it will properly handle the difference between debug and release // Auxio instances. diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 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/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 6d9bc9e9b..d29b513a6 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 synchronized and volatile in shared objs */ @AndroidEntryPoint class MainActivity : AppCompatActivity() { diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 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 7a7ecf00e..3bfd9ab89 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. @@ -218,7 +218,7 @@ class AlbumDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = album.name.resolve(requireContext()) + requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext()) albumHeaderAdapter.setParent(album) } @@ -317,6 +317,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 55ebb6ff5..ef016d57d 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) @@ -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 @@ -122,7 +125,7 @@ class ArtistDetailFragment : override fun onDestroyBinding(binding: FragmentDetailBinding) { super.onDestroyBinding(binding) - binding.detailToolbar.setOnMenuItemClickListener(null) + binding.detailNormalToolbar.setOnMenuItemClickListener(null) binding.detailRecycler.adapter = null // Avoid possible race conditions that could cause a bad replace instruction to be consumed // during list initialization and crash the app. Could happen if the user is fast enough. @@ -227,7 +230,7 @@ class ArtistDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = artist.name.resolve(requireContext()) + requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext()) artistHeaderAdapter.setParent(artist) } @@ -287,6 +290,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 127c84468..a1682ffb7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -30,6 +30,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 +146,7 @@ constructor( } // --- PLAYLIST --- + private val _currentPlaylist = MutableStateFlow(null) /** The current [Playlist] to display. Null if there is nothing to do. */ val currentPlaylist: StateFlow @@ -158,14 +160,13 @@ constructor( val playlistInstructions: Event get() = _playlistInstructions - /** 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 @@ -218,6 +219,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) } @@ -283,6 +285,91 @@ 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 savePlaylistEdit() { + val playlist = _currentPlaylist.value ?: return + val editedPlaylist = _editedPlaylist.value ?: return + viewModelScope.launch { + musicRepository.rewritePlaylist(playlist, editedPlaylist) + // TODO: The user could probably press some kind of button if they were fast enough. + // Think of a better way to handle this state. + _editedPlaylist.value = null + } + } + + /** + * End a playlist editing session and keep the prior state. Does nothing if there was no prior + * editing session. + * + * @return true if the session was ended, false otherwise. + */ + fun dropPlaylistEdit(): Boolean { + val playlist = _currentPlaylist.value ?: return false + if (_editedPlaylist.value == null) { + // Nothing to do. + return false + } + _editedPlaylist.value = null + refreshPlaylistList(playlist) + return true + } + + /** + * (Visually) move a song in the current playlist. Does nothing if not in an editing session. + * + * @param from The start position, in the list adapter data. + * @param to The destination position, in the list adapter data. + * @return true if the song was moved, false otherwise. + */ + fun movePlaylistSongs(from: Int, to: Int): Boolean { + // TODO: Song re-sorting + val playlist = _currentPlaylist.value ?: return false + val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList() + val realFrom = from - 2 + val realTo = to - 2 + if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) { + return false + } + editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo)) + _editedPlaylist.value = editedPlaylist + refreshPlaylistList(playlist, UpdateInstructions.Move(from, to)) + return true + } + + /** + * (Visually) remove a song in the current playlist. Does nothing if not in an editing session. + * + * @param at The position of the item to remove, in the list adapter data. + */ + fun removePlaylistSong(at: Int) { + val playlist = _currentPlaylist.value ?: return + val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList() + val realAt = at - 2 + if (realAt !in editedPlaylist.indices) { + return + } + editedPlaylist.removeAt(realAt) + _editedPlaylist.value = editedPlaylist + refreshPlaylistList( + playlist, + if (editedPlaylist.isNotEmpty()) { + UpdateInstructions.Remove(at, 1) + } else { + UpdateInstructions.Remove(at - 2, 3) + }) + } + private fun refreshAudioInfo(song: Song) { // Clear any previous job in order to avoid stale data from appearing in the UI. currentSongJob?.cancel() @@ -406,20 +493,21 @@ constructor( _genreList.value = list } - private fun refreshPlaylistList(playlist: Playlist, replace: Boolean = false) { + private fun refreshPlaylistList( + playlist: Playlist, + instructions: UpdateInstructions = UpdateInstructions.Diff + ) { logD("Refreshing playlist list") - var instructions: UpdateInstructions = UpdateInstructions.Diff val list = mutableListOf() - if (playlist.songs.isNotEmpty()) { - val header = SortHeader(R.string.lbl_songs) + val songs = editedPlaylist.value ?: playlist.songs + if (songs.isNotEmpty()) { + val header = EditHeader(R.string.lbl_songs) list.add(Divider(header)) list.add(header) - if (replace) { - instructions = UpdateInstructions.Replace(list.size) - } - list.addAll(playlistSongSort.songs(playlist.songs)) + list.addAll(songs) } + _playlistInstructions.put(instructions) _playlistList.value = list } 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 e094a5a70..562690b8f 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) @@ -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 @@ -115,7 +118,7 @@ class GenreDetailFragment : override fun onDestroyBinding(binding: FragmentDetailBinding) { super.onDestroyBinding(binding) - binding.detailToolbar.setOnMenuItemClickListener(null) + binding.detailNormalToolbar.setOnMenuItemClickListener(null) binding.detailRecycler.adapter = null // Avoid possible race conditions that could cause a bad replace instruction to be consumed // during list initialization and crash the app. Could happen if the user is fast enough. @@ -218,7 +221,7 @@ class GenreDetailFragment : findNavController().navigateUp() return } - requireBinding().detailToolbar.title = genre.name.resolve(requireContext()) + requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext()) genreHeaderAdapter.setParent(genre) } @@ -264,6 +267,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 86eba91bb..8e3fed795 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) @@ -84,17 +90,29 @@ 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 = + ItemTouchHelper(PlaylistDragCallback(detailModel)).also { + it.attachToRecyclerView(this) + } (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 @@ -107,21 +125,53 @@ 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) + binding.detailNormalToolbar.setOnMenuItemClickListener(null) + touchHelper = null binding.detailRecycler.adapter = null // Avoid possible race conditions that could cause a bad replace instruction to be consumed // during list initialization and crash the app. Could happen if the user is fast enough. detailModel.playlistInstructions.consume() } + override fun onDestinationChanged( + controller: NavController, + destination: NavDestination, + arguments: Bundle? + ) { + // Drop the initial call by NavController that simply provides us with the current + // destination. This would cause the selection state to be lost every time the device + // rotates. + if (!initialNavDestinationChange) { + initialNavDestinationChange = true + return + } + // Drop any pending playlist edits when navigating away. This could actually happen + // if the user is quick enough. + detailModel.dropPlaylistEdit() + } + override fun onMenuItemClick(item: MenuItem): Boolean { if (super.onMenuItemClick(item)) { return true @@ -151,6 +201,10 @@ class PlaylistDetailFragment : requireContext().share(currentPlaylist) true } + R.id.action_save -> { + detailModel.savePlaylistEdit() + true + } else -> false } } @@ -159,8 +213,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) { - openMusicMenu(anchor, R.menu.menu_song_actions, item) + openMusicMenu(anchor, R.menu.menu_playlist_song_actions, item) } override fun onPlay() { @@ -171,48 +229,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 - } - - 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 onStartEdit() { + detailModel.startPlaylistEdit() } + override fun onOpenSortMenu(anchor: View) {} + private fun updatePlaylist(playlist: Playlist?) { if (playlist == null) { // Playlist we were showing no longer exists. findNavController().navigateUp() return } - 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) } @@ -254,8 +285,38 @@ class PlaylistDetailFragment : playlistListAdapter.update(list, detailModel.playlistInstructions.consume()) } + private fun updateEditedPlaylist(editedPlaylist: List?) { + playlistListAdapter.setEditing(editedPlaylist != null) + playlistHeaderAdapter.setEditedPlaylist(editedPlaylist) + selectionModel.drop() + + if (editedPlaylist != null) { + requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply { + isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs + } + } + + updateMultiToolbar() + } + private fun updateSelection(selected: List) { playlistListAdapter.setSelected(selected.toSet()) - 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/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,40 @@ 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 + ) { + // TODO: Debug perpetually re-binding images + binding.detailCover.bind(playlist, editedPlaylist) binding.detailType.text = binding.context.getString(R.string.lbl_playlist) binding.detailName.text = playlist.name.resolve(binding.context) // Nothing about a playlist is applicable to the sub-head text. binding.detailSubhead.isVisible = false + val songs = editedPlaylist ?: playlist.songs + val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs // The song count of the playlist maps to the info text. - binding.detailInfo.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/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..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 @@ -18,53 +18,265 @@ package org.oxycblt.auxio.detail.list +import android.annotation.SuppressLint +import android.graphics.drawable.LayerDrawable +import android.view.View import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.appcompat.widget.TooltipCompat +import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.shape.MaterialShapeDrawable +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.ItemEditHeaderBinding +import org.oxycblt.auxio.databinding.ItemEditableSongBinding +import org.oxycblt.auxio.list.EditableListListener +import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter +import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SimpleDiffCallback +import org.oxycblt.auxio.list.recycler.MaterialDragCallback import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.getAttrColorCompat +import org.oxycblt.auxio.util.getDimen +import org.oxycblt.auxio.util.inflater /** - * A [DetailListAdapter] implementing the header 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 - 1, PAYLOAD_EDITING_CHANGED) + } + + /** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */ + interface Listener : DetailListAdapter.Listener, EditableListListener { + /** Called when the "edit" option is selected in the edit header. */ + fun onStartEdit() + } + + /** + * A [RecyclerView.ViewHolder] extension required to respond to changes in the editing state. + */ + interface ViewHolder { + /** + * Called when the editing state changes. Implementations should update UI options as needed + * to reflect the new state. + * + * @param editing Whether the data is currently being edited or not. + */ + fun updateEditing(editing: Boolean) + } + + private companion object { + val PAYLOAD_EDITING_CHANGED = Any() + val DIFF_CALLBACK = object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item) = when { oldItem is Song && newItem is Song -> - 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. + * + * @author Alexander Capehart (OxygenCobalt) + */ +private class EditHeaderViewHolder private constructor(private val binding: ItemEditHeaderBinding) : + RecyclerView.ViewHolder(binding.root), PlaylistDetailListAdapter.ViewHolder { + /** + * Bind new data to this instance. + * + * @param editHeader The new [EditHeader] to bind. + * @param listener An [PlaylistDetailListAdapter.Listener] to bind interactions to. + */ + fun bind(editHeader: EditHeader, listener: PlaylistDetailListAdapter.Listener) { + binding.headerTitle.text = binding.context.getString(editHeader.titleRes) + // Add a Tooltip based on the content description so that the purpose of this + // button can be clear. + binding.headerEdit.apply { + TooltipCompat.setTooltipText(this, contentDescription) + setOnClickListener { listener.onStartEdit() } + } + } + + override fun updateEditing(editing: Boolean) { + binding.headerEdit.isEnabled = !editing + } + + companion object { + /** A unique ID for this [RecyclerView.ViewHolder] type. */ + const val VIEW_TYPE = IntegerTable.VIEW_TYPE_EDIT_HEADER + + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: View) = + EditHeaderViewHolder(ItemEditHeaderBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: EditHeader, newItem: EditHeader) = + oldItem.titleRes == newItem.titleRes + } + } +} + +/** + * A [PlayingIndicatorAdapter.ViewHolder] that displays a queue [Song] which can be re-ordered and + * removed. Use [from] to create an instance. + * + * @author Alexander Capehart (OxygenCobalt) + */ +private class PlaylistSongViewHolder +private constructor(private val binding: ItemEditableSongBinding) : + SelectionIndicatorAdapter.ViewHolder(binding.root), + MaterialDragCallback.ViewHolder, + PlaylistDetailListAdapter.ViewHolder { + override val enabled: Boolean + get() = binding.songDragHandle.isVisible + override val root = binding.root + override val body = binding.body + override val delete = binding.background + override val background = + MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply { + fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) + elevation = binding.context.getDimen(R.dimen.elevation_normal) + alpha = 0 + } + + init { + binding.body.background = + LayerDrawable( + arrayOf( + MaterialShapeDrawable.createWithElevationOverlay(binding.context).apply { + fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) + }, + background)) + } + + /** + * Bind new data to this instance. + * + * @param song The new [Song] to bind. + * @param listener A [PlaylistDetailListAdapter.Listener] to bind interactions to. + */ + @SuppressLint("ClickableViewAccessibility") + fun bind(song: Song, listener: PlaylistDetailListAdapter.Listener) { + listener.bind(song, this, binding.interactBody, menuButton = binding.songMenu) + listener.bind(this, binding.songDragHandle) + binding.songAlbumCover.bind(song) + binding.songName.text = song.name.resolve(binding.context) + binding.songInfo.text = song.artists.resolveNames(binding.context) + // Not swiping this ViewHolder if it's being re-bound, ensure that the background is + // not visible. See MaterialDragCallback for why this is done. + binding.background.isInvisible = true + } + + override fun updateSelectionIndicator(isSelected: Boolean) { + binding.interactBody.isActivated = isSelected + binding.songAlbumCover.isActivated = isSelected + } + + override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { + binding.interactBody.isSelected = isActive + binding.songAlbumCover.isPlaying = isPlaying + } + + override fun updateEditing(editing: Boolean) { + binding.songDragHandle.isInvisible = !editing + binding.songMenu.isInvisible = editing + binding.interactBody.isEnabled = !editing + } + + companion object { + /** A unique ID for this [RecyclerView.ViewHolder] type. */ + const val VIEW_TYPE = IntegerTable.VIEW_TYPE_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/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/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/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/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/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..2e04617e5 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt @@ -96,49 +96,54 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * * @param song The [Song] to bind. */ - fun bind(song: Song) = bindImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover) + fun bind(song: Song) = bind(song.album) /** * Bind an [Album]'s cover to this view, also updating the content description. * * @param album the [Album] to bind. */ - fun bind(album: Album) = bindImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover) + fun bind(album: Album) = bind(album, R.drawable.ic_album_24, R.string.desc_album_cover) /** * Bind an [Artist]'s image to this view, also updating the content description. * * @param artist the [Artist] to bind. */ - fun bind(artist: Artist) = bindImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image) + fun bind(artist: Artist) = bind(artist, R.drawable.ic_artist_24, R.string.desc_artist_image) /** * Bind an [Genre]'s image to this view, also updating the content description. * * @param genre the [Genre] to bind. */ - fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image) + fun bind(genre: Genre) = bind(genre, R.drawable.ic_genre_24, R.string.desc_genre_image) /** * 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 music The music to find. - * @param errorRes The error drawable resource to use if the music cannot be loaded. - * @param descRes The content description string resource to use. The resource must have one - * field for the name of the [Music]. - */ - private fun bindImpl(music: Music, @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(music) + .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, music.name.resolve(context)) + contentDescription = desc } /** diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 12ef10a50..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,162 +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()}" } -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..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 @@ -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,13 @@ 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.list.Sort 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 +63,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) { + openInputStream(album)?.let(streams::add) + if (streams.size == 4) { + return createMosaic(streams, size) + } + } + + return streams.firstOrNull()?.let { stream -> + SourceResult( + source = ImageSource(stream.source().buffer(), context), + mimeType = null, + dataSource = DataSource.DISK) + } + } + + fun computeAlbumOrdering(songs: List): Collection = + Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(songs.groupBy { it.album }.keys) + + private suspend fun openInputStream(album: Album): InputStream? = try { when (imageSettings.coverMode) { CoverMode.OFF -> null @@ -125,4 +163,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/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/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/list/recycler/MaterialDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt new file mode 100644 index 000000000..ea5629e78 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2021 Auxio Project + * MaterialDragCallback.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.list.recycler + +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import androidx.core.view.isInvisible +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.R +import org.oxycblt.auxio.util.getDimen +import org.oxycblt.auxio.util.getInteger +import org.oxycblt.auxio.util.logD + +/** + * A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in editable UIs, + * such as an animation when lifting items. Note that this requires a [ViewHolder] implementation in + * order to function. + * + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class MaterialDragCallback : ItemTouchHelper.Callback() { + private var shouldLift = true + + final override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ) = + if (viewHolder is ViewHolder && viewHolder.enabled) { + makeFlag( + ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or + makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START) + } else { + 0 + } + + final override fun onChildDraw( + c: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + val holder = viewHolder as ViewHolder + + // Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure + // this is only done once when the item is initially picked up. + // TODO: I think this is possible to improve with a raw ValueAnimator. + if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + logD("Lifting item") + + val bg = holder.background + val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal) + holder.root + .animate() + .translationZ(elevation) + .setDuration( + recyclerView.context.getInteger(R.integer.anim_fade_exit_duration).toLong()) + .setUpdateListener { + bg.alpha = ((holder.root.translationZ / elevation) * 255).toInt() + } + .setInterpolator(AccelerateDecelerateInterpolator()) + .start() + + shouldLift = false + } + + // We show a background with a delete icon behind the item each time one is swiped + // away. To avoid working with canvas, this is simply placed behind the body. + // That comes with a couple of problems, however. For one, the background view will always + // lag behind the body view, resulting in a noticeable pixel offset when dragging. To fix + // this, we make this a separate view and make this view invisible whenever the item is + // not being swiped. This issue is also the reason why the background is not merged with + // the FrameLayout within the item. + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { + holder.delete.isInvisible = dX == 0f + } + + // Update other translations. We do not call the default implementation, so we must do + // this ourselves. + holder.body.translationX = dX + holder.root.translationY = dY + } + + final override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + // When an elevated item is cleared, we reset the elevation using another animation. + val holder = viewHolder as ViewHolder + + // This function can be called multiple times, so only start the animation when the view's + // translationZ is already non-zero. + if (holder.root.translationZ != 0f) { + logD("Dropping item") + + val bg = holder.background + val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal) + holder.root + .animate() + .translationZ(0f) + .setDuration( + recyclerView.context.getInteger(R.integer.anim_fade_exit_duration).toLong()) + .setUpdateListener { + bg.alpha = ((holder.root.translationZ / elevation) * 255).toInt() + } + .setInterpolator(AccelerateDecelerateInterpolator()) + .start() + } + + shouldLift = true + + // Reset translations. We do not call the default implementation, so we must do + // this ourselves. + holder.body.translationX = 0f + holder.root.translationY = 0f + } + + // Long-press events are too buggy, only allow dragging with the handle. + final override fun isLongPressDragEnabled() = false + + /** Required [RecyclerView.ViewHolder] implementation that exposes the following. */ + interface ViewHolder { + /** Whether this [ViewHolder] can be moved right now. */ + val enabled: Boolean + /** The root view containing the delete scrim and information. */ + val root: View + /** The body view containing music information. */ + val body: View + /** The scrim view showing the delete icon. Should be behind [body]. */ + val delete: View + /** The drawable of the [body] background that can be elevated. */ + val background: Drawable + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt index 2cb2403da..88bdba6d8 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 @@ -40,20 +40,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 05b203771..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * SelectionToolbarOverlay.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.list.selection - -import android.animation.ValueAnimator -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout -import androidx.annotation.AttrRes -import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener -import androidx.core.view.isInvisible -import com.google.android.material.appbar.MaterialToolbar -import org.oxycblt.auxio.R -import org.oxycblt.auxio.util.getInteger -import org.oxycblt.auxio.util.logD - -/** - * A wrapper around a [MaterialToolbar] that adds an additional [MaterialToolbar] showing the - * current selection state. - * - * @author Alexander Capehart (OxygenCobalt) - */ -class SelectionToolbarOverlay -@JvmOverloads -constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : - FrameLayout(context, attrs, defStyleAttr) { - private lateinit var innerToolbar: MaterialToolbar - private val selectionToolbar = - MaterialToolbar(context).apply { - setNavigationIcon(R.drawable.ic_close_24) - inflateMenu(R.menu.menu_selection_actions) - - if (isInEditMode) { - isInvisible = true - } - } - private var fadeThroughAnimator: ValueAnimator? = null - - override fun onFinishInflate() { - super.onFinishInflate() - // Sanity check: Avoid incorrect views from being included in this layout. - check(childCount == 1 && getChildAt(0) is MaterialToolbar) { - "SelectionToolbarOverlay Must have only one MaterialToolbar child" - } - // The inner toolbar should be the first child. - innerToolbar = getChildAt(0) as MaterialToolbar - // Selection toolbar should appear on top of the inner toolbar. - addView(selectionToolbar) - } - - /** - * Set an OnClickListener for when the "cancel" button in the selection [MaterialToolbar] is - * pressed. - * - * @param listener The OnClickListener to respond to this interaction. - * @see MaterialToolbar.setNavigationOnClickListener - */ - fun setOnSelectionCancelListener(listener: OnClickListener) { - selectionToolbar.setNavigationOnClickListener(listener) - } - - /** - * Set an [OnMenuItemClickListener] for when a MenuItem is selected from the selection - * [MaterialToolbar]. - * - * @param listener The [OnMenuItemClickListener] to respond to this interaction. - * @see MaterialToolbar.setOnMenuItemClickListener - */ - fun setOnMenuItemClickListener(listener: OnMenuItemClickListener?) { - selectionToolbar.setOnMenuItemClickListener(listener) - } - - /** - * Update the selection [MaterialToolbar] to reflect the current selection amount. - * - * @param amount The amount of items that are currently selected. - * @return true if the selection [MaterialToolbar] changes, false otherwise. - */ - fun updateSelectionAmount(amount: Int): Boolean { - logD("Updating selection amount to $amount") - return if (amount > 0) { - // Only update the selected amount when it's non-zero to prevent a strange - // title text. - selectionToolbar.title = context.getString(R.string.fmt_selected, amount) - animateToolbarsVisibility(true) - } else { - animateToolbarsVisibility(false) - } - } - - /** - * Animate the visibility of the inner and selection [MaterialToolbar]s to the given state. - * - * @param selectionVisible Whether the selection [MaterialToolbar] should be visible or not. - * @return true if the toolbars have changed, false otherwise. - */ - private fun animateToolbarsVisibility(selectionVisible: Boolean): Boolean { - // TODO: Animate nicer Material Fade transitions using animators (Normal transitions - // don't work due to translation) - // Set up the target transitions for both the inner and selection toolbars. - val targetInnerAlpha: Float - val targetSelectionAlpha: Float - val targetDuration: Long - - if (selectionVisible) { - targetInnerAlpha = 0f - targetSelectionAlpha = 1f - targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong() - } else { - targetInnerAlpha = 1f - targetSelectionAlpha = 0f - targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong() - } - - if (innerToolbar.alpha == targetInnerAlpha && - selectionToolbar.alpha == targetSelectionAlpha) { - // Nothing to do. - return false - } - - if (!isLaidOut) { - // Not laid out, just change it immediately while are not shown to the user. - // This is an initialization, so we return false despite changing. - setToolbarsAlpha(targetInnerAlpha) - return false - } - - if (fadeThroughAnimator != null) { - fadeThroughAnimator?.cancel() - fadeThroughAnimator = null - } - - fadeThroughAnimator = - ValueAnimator.ofFloat(innerToolbar.alpha, targetInnerAlpha).apply { - duration = targetDuration - addUpdateListener { setToolbarsAlpha(it.animatedValue as Float) } - start() - } - - return true - } - - /** - * Update the alpha of the inner and selection [MaterialToolbar]s. - * - * @param innerAlpha The opacity of the inner [MaterialToolbar]. This will map to the inverse - * opacity of the selection [MaterialToolbar]. - */ - private fun setToolbarsAlpha(innerAlpha: Float) { - innerToolbar.apply { - alpha = innerAlpha - isInvisible = innerAlpha == 0f - } - - selectionToolbar.apply { - alpha = 1 - innerAlpha - isInvisible = innerAlpha == 1f - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index 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..6fa0b4f79 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,15 @@ 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]. + * + * @param playlist The [Playlist] to update. + * @param songs The new [Song]s to be contained in the [Playlist]. + */ + suspend fun rewritePlaylist(playlist: Playlist, songs: List) /** * Request that a music loading operation is started by the current [IndexingWorker]. Does @@ -211,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 @@ -264,46 +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 fun createPlaylist(name: String, songs: List) { - val userLibrary = userLibrary ?: return + override suspend fun createPlaylist(name: String, songs: List) { + val userLibrary = synchronized(this) { userLibrary ?: return } userLibrary.createPlaylist(name, songs) - for (listener in updateListeners) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) - } + notifyUserLibraryChange() } - override fun renamePlaylist(playlist: Playlist, name: String) { - val userLibrary = userLibrary ?: return + override suspend fun renamePlaylist(playlist: Playlist, name: String) { + val userLibrary = synchronized(this) { userLibrary ?: return } userLibrary.renamePlaylist(playlist, name) - for (listener in updateListeners) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) - } + notifyUserLibraryChange() } - override fun deletePlaylist(playlist: Playlist) { - val userLibrary = userLibrary ?: return + override suspend fun deletePlaylist(playlist: Playlist) { + val userLibrary = synchronized(this) { userLibrary ?: return } userLibrary.deletePlaylist(playlist) - for (listener in updateListeners) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) - } + notifyUserLibraryChange() } - override fun addToPlaylist(songs: List, playlist: Playlist) { - val userLibrary = userLibrary ?: return + override suspend fun addToPlaylist(songs: List, playlist: Playlist) { + val userLibrary = synchronized(this) { userLibrary ?: return } userLibrary.addToPlaylist(playlist, songs) + notifyUserLibraryChange() + } + + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { + val userLibrary = synchronized(this) { userLibrary ?: return } + userLibrary.rewritePlaylist(playlist, songs) + notifyUserLibraryChange() + } + + @Synchronized + private fun notifyUserLibraryChange() { for (listener in updateListeners) { listener.onMusicChanges( MusicRepository.Changes(deviceLibrary = false, userLibrary = true)) } } + @Synchronized override fun requestIndex(withCache: Boolean) { indexingWorker?.requestIndex(withCache) } @@ -383,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 = 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/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/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 56514f7dd..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) { - if (!changes.deviceLibrary) return 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/PlaylistDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt deleted file mode 100644 index 3377b172a..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * PlaylistDatabase.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music.user - -import androidx.room.* -import org.oxycblt.auxio.music.Music - -@Database( - entities = [PlaylistInfo::class, PlaylistSong::class, PlaylistSongCrossRef::class], - version = 28, - exportSchema = false) -@TypeConverters(Music.UID.TypeConverters::class) -abstract class PlaylistDatabase : RoomDatabase() { - abstract fun playlistDao(): PlaylistDao -} - -@Dao -interface PlaylistDao { - @Transaction @Query("SELECT * FROM PlaylistInfo") fun readRawPlaylists(): List -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt index 6f56be360..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 @@ -21,6 +21,11 @@ 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 +35,28 @@ 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 563f99316..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,37 +87,54 @@ 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]. + * + * @param playlist The [Playlist] to update. + * @param songs The new [Song]s to be contained in the [Playlist]. + */ + suspend fun rewritePlaylist(playlist: Playlist, songs: List) } class UserLibraryFactoryImpl @Inject constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : UserLibrary.Factory { - override suspend fun read(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() @@ -124,28 +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) { - playlistMap.remove(playlist.uid) + 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) }) + } + + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { + val playlistImpl = + requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" } + synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(songs) } + playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt index b4c7ef6a4..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,18 +30,18 @@ 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 @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/UserMusicDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt new file mode 100644 index 000000000..ed790640a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2023 Auxio Project + * UserMusicDatabase.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.user + +import androidx.room.* +import org.oxycblt.auxio.music.Music + +/** + * Allows persistence of all user-created music information. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@Database( + entities = [PlaylistInfo::class, PlaylistSong::class, PlaylistSongCrossRef::class], + version = 30, + exportSchema = false) +@TypeConverters(Music.UID.TypeConverters::class) +abstract class UserMusicDatabase : RoomDatabase() { + abstract fun playlistDao(): PlaylistDao +} + +// TODO: Handle playlist defragmentation? I really don't want dead songs to accumulate in this +// database. + +/** + * The DAO for persisted playlist information. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@Dao +interface PlaylistDao { + /** + * Read out all playlists stored in the database. + * + * @return A list of [RawPlaylist] representing each playlist stored. + */ + @Transaction + @Query("SELECT * FROM PlaylistInfo") + suspend fun readRawPlaylists(): List + + /** + * Create a new playlist. + * + * @param rawPlaylist The [RawPlaylist] to create. + */ + @Transaction + suspend fun insertPlaylist(rawPlaylist: RawPlaylist) { + insertInfo(rawPlaylist.playlistInfo) + insertSongs(rawPlaylist.songs) + insertRefs( + rawPlaylist.songs.map { + PlaylistSongCrossRef( + playlistUid = rawPlaylist.playlistInfo.playlistUid, songUid = it.songUid) + }) + } + + /** + * Replace the currently-stored [PlaylistInfo] for a playlist entry. + * + * @param playlistInfo The new [PlaylistInfo] to store. + */ + @Transaction + suspend fun replacePlaylistInfo(playlistInfo: PlaylistInfo) { + deleteInfo(playlistInfo.playlistUid) + insertInfo(playlistInfo) + } + + /** + * Delete a playlist entry's [PlaylistInfo] and [PlaylistSong]. + * + * @param playlistUid The [Music.UID] of the playlist to delete. + */ + @Transaction + suspend fun deletePlaylist(playlistUid: Music.UID) { + deleteInfo(playlistUid) + deleteRefs(playlistUid) + } + + /** + * Insert new song entries into a playlist. + * + * @param playlistUid The [Music.UID] of the playlist to insert into. + * @param songs The [PlaylistSong] representing each song to put into the playlist. + */ + @Transaction + suspend fun insertPlaylistSongs(playlistUid: Music.UID, songs: List) { + insertSongs(songs) + insertRefs( + songs.map { PlaylistSongCrossRef(playlistUid = playlistUid, songUid = it.songUid) }) + } + + /** + * Replace the currently-stored [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) +} 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 b02964c8d..2cce12949 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 @@ -51,6 +52,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * available controls. * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Improve flickering situation on play button */ @AndroidEntryPoint class PlaybackPanelFragment : @@ -58,6 +61,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 @@ -165,6 +169,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/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/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt index 1ccf3b4ab..434e8f479 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt @@ -306,7 +306,7 @@ class EditableQueue : Queue { else -> Queue.Change.Type.MAPPING } check() - return Queue.Change(type, UpdateInstructions.Remove(at)) + return Queue.Change(type, UpdateInstructions.Remove(at, 1)) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index df4ac8c1d..76625a038 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -26,9 +26,10 @@ import androidx.core.view.isInvisible import androidx.recyclerview.widget.RecyclerView import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.ItemQueueSongBinding -import org.oxycblt.auxio.list.EditableListListener +import org.oxycblt.auxio.databinding.ItemEditableSongBinding +import org.oxycblt.auxio.list.EditClickListListener import org.oxycblt.auxio.list.adapter.* +import org.oxycblt.auxio.list.recycler.MaterialDragCallback import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames @@ -37,10 +38,10 @@ import org.oxycblt.auxio.util.* /** * A [RecyclerView.Adapter] that shows an editable list of queue items. * - * @param listener A [EditableListListener] to bind interactions to. + * @param listener A [EditClickListListener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class QueueAdapter(private val listener: EditableListListener) : +class QueueAdapter(private val listener: EditClickListListener) : FlexibleListAdapter(QueueSongViewHolder.DIFF_CALLBACK) { // Since PlayingIndicator adapter relies on an item value, we cannot use it for this // adapter, as one item can appear at several points in the UI. Use a similar implementation @@ -96,34 +97,27 @@ class QueueAdapter(private val listener: EditableListListener) : } /** - * A [PlayingIndicatorAdapter.ViewHolder] that displays a queue [Song]. Use [from] to create an - * instance. + * A [PlayingIndicatorAdapter.ViewHolder] that displays an queue [Song] which can be re-ordered and + * removed. Use [from] to create an instance. * * @author Alexander Capehart (OxygenCobalt) */ -class QueueSongViewHolder private constructor(private val binding: ItemQueueSongBinding) : - PlayingIndicatorAdapter.ViewHolder(binding.root) { - /** The "body" view of this [QueueSongViewHolder] that shows the [Song] information. */ - val bodyView: View - get() = binding.body - - /** The background view of this [QueueSongViewHolder] that shows the delete icon. */ - val backgroundView: View - get() = binding.background - - /** The actual background drawable of this [QueueSongViewHolder] that can be manipulated. */ - val backgroundDrawable = +class QueueSongViewHolder private constructor(private val binding: ItemEditableSongBinding) : + PlayingIndicatorAdapter.ViewHolder(binding.root), MaterialDragCallback.ViewHolder { + override val enabled = true + override val root = binding.root + override val body = binding.body + override val delete = binding.background + override val background = MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply { fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) elevation = binding.context.getDimen(R.dimen.elevation_normal) * 5 alpha = 0 } - /** If this queue item is considered "in the future" (i.e has not played yet). */ var isFuture: Boolean get() = binding.songAlbumCover.isEnabled set(value) { - // Don't want to disable clicking, just indicate the body and handle is disabled binding.songAlbumCover.isEnabled = value binding.songName.isEnabled = value binding.songInfo.isEnabled = value @@ -137,18 +131,18 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface) elevation = binding.context.getDimen(R.dimen.elevation_normal) }, - backgroundDrawable)) + background)) } /** * Bind new data to this instance. * * @param song The new [Song] to bind. - * @param listener A [EditableListListener] to bind interactions to. + * @param listener A [EditClickListListener] to bind interactions to. */ @SuppressLint("ClickableViewAccessibility") - fun bind(song: Song, listener: EditableListListener) { - listener.bind(song, this, bodyView, binding.songDragHandle) + fun bind(song: Song, listener: EditClickListListener) { + listener.bind(song, this, body, binding.songDragHandle) binding.songAlbumCover.bind(song) binding.songName.text = song.name.resolve(binding.context) binding.songInfo.text = song.artists.resolveNames(binding.context) @@ -170,7 +164,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong * @return A new instance. */ fun from(parent: View) = - QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater)) + QueueSongViewHolder(ItemEditableSongBinding.inflate(parent.context.inflater)) /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt index 5b61eb7c4..23d45f62a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt @@ -18,15 +18,9 @@ package org.oxycblt.auxio.playback.queue -import android.graphics.Canvas -import android.view.animation.AccelerateDecelerateInterpolator -import androidx.core.view.isInvisible import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView -import org.oxycblt.auxio.R -import org.oxycblt.auxio.util.getDimen -import org.oxycblt.auxio.util.getInteger -import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.list.recycler.MaterialDragCallback /** * A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in the queue UI, @@ -34,108 +28,16 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() { - private var shouldLift = true - - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) = - makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or - makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START) - - override fun onChildDraw( - c: Canvas, - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - dX: Float, - dY: Float, - actionState: Int, - isCurrentlyActive: Boolean - ) { - val holder = viewHolder as QueueSongViewHolder - - // Hook drag events to "lifting" the queue item (i.e raising it's elevation). Make sure - // this is only done once when the item is initially picked up. - // TODO: I think this is possible to improve with a raw ValueAnimator. - if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { - logD("Lifting queue item") - - val bg = holder.backgroundDrawable - val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal) - holder.itemView - .animate() - .translationZ(elevation) - .setDuration( - recyclerView.context.getInteger(R.integer.anim_fade_exit_duration).toLong()) - .setUpdateListener { - bg.alpha = ((holder.itemView.translationZ / elevation) * 255).toInt() - } - .setInterpolator(AccelerateDecelerateInterpolator()) - .start() - - shouldLift = false - } - - // We show a background with a delete icon behind the queue song each time one is swiped - // away. To avoid working with canvas, this is simply placed behind the queue body. - // That comes with a couple of problems, however. For one, the background view will always - // lag behind the body view, resulting in a noticeable pixel offset when dragging. To fix - // this, we make this a separate view and make this view invisible whenever the item is - // not being swiped. This issue is also the reason why the background is not merged with - // the FrameLayout within the queue item. - if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { - holder.backgroundView.isInvisible = dX == 0f - } - - // Update other translations. We do not call the default implementation, so we must do - // this ourselves. - holder.bodyView.translationX = dX - holder.itemView.translationY = dY - } - - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - // When an elevated item is cleared, we reset the elevation using another animation. - val holder = viewHolder as QueueSongViewHolder - - // This function can be called multiple times, so only start the animation when the view's - // translationZ is already non-zero. - if (holder.itemView.translationZ != 0f) { - logD("Dropping queue item") - - val bg = holder.backgroundDrawable - val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal) - holder.itemView - .animate() - .translationZ(0f) - .setDuration( - recyclerView.context.getInteger(R.integer.anim_fade_exit_duration).toLong()) - .setUpdateListener { - bg.alpha = ((holder.itemView.translationZ / elevation) * 255).toInt() - } - .setInterpolator(AccelerateDecelerateInterpolator()) - .start() - } - - shouldLift = true - - // Reset translations. We do not call the default implementation, so we must do - // this ourselves. - holder.bodyView.translationX = 0f - holder.itemView.translationY = 0f - } - +class QueueDragCallback(private val queueModel: QueueViewModel) : MaterialDragCallback() { override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder - ): Boolean { - logD("${viewHolder.bindingAdapterPosition} ${target.bindingAdapterPosition}") - return playbackModel.moveQueueDataItems( + ) = + queueModel.moveQueueDataItems( viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) - } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - playbackModel.removeQueueDataItem(viewHolder.bindingAdapterPosition) + queueModel.removeQueueDataItem(viewHolder.bindingAdapterPosition) } - - // Long-press events are too buggy, only allow dragging with the handle. - override fun isLongPressDragEnabled() = false } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index e39348451..414ab0eeb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -28,7 +28,7 @@ import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentQueueBinding -import org.oxycblt.auxio.list.EditableListListener +import org.oxycblt.auxio.list.EditClickListListener import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment @@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class QueueFragment : ViewBindingFragment(), EditableListListener { +class QueueFragment : ViewBindingFragment(), EditClickListListener { private val queueModel: QueueViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels() private val queueAdapter = QueueAdapter(this) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index c15982e03..63fb85ed2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -534,17 +534,24 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { val internalPlayer = internalPlayer ?: return logD("Restoring state $savedState") + val lastSong = queue.currentSong parent = savedState.parent queue.applySavedState(savedState.queueState) repeatMode = savedState.repeatMode notifyNewPlayback() - // Continuing playback while also possibly doing drastic state updates is - // a bad idea, so pause. - internalPlayer.loadSong(queue.currentSong, false) - if (queue.currentSong != null) { - // Internal player may have reloaded the media item, re-seek to the previous position - seekTo(savedState.positionMs) + // Check if we need to reload the player with a new music file, or if we can just leave + // it be. Specifically done so we don't pause on music updates that don't really change + // what's playing (ex. playlist editing) + if (lastSong != queue.currentSong) { + // Continuing playback while also possibly doing drastic state updates is + // a bad idea, so pause. + internalPlayer.loadSong(queue.currentSong, false) + if (queue.currentSong != null) { + // Internal player may have reloaded the media item, re-seek to the previous + // position + seekTo(savedState.positionMs) + } } isInitialized = true } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index b0a0feb06..a7b29b204 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 @@ -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 } } @@ -126,7 +129,7 @@ class SearchFragment : ListFragment() { override fun onDestroyBinding(binding: FragmentSearchBinding) { super.onDestroyBinding(binding) - binding.searchToolbar.setOnMenuItemClickListener(null) + binding.searchNormalToolbar.setOnMenuItemClickListener(null) binding.searchRecycler.adapter = null } @@ -198,10 +201,16 @@ class SearchFragment : ListFragment() { private fun updateSelection(selected: List) { searchAdapter.setSelected(selected.toSet()) - if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) && - selected.isNotEmpty()) { - // Make selection of obscured items easier by hiding the keyboard. - hideKeyboard() + val binding = requireBinding() + if (selected.isNotEmpty()) { + binding.searchSelectionToolbar.title = getString(R.string.fmt_selected, selected.size) + if (binding.searchToolbar.setVisible(R.id.search_selection_toolbar)) { + // New selection started, show the keyboard to make selection easier. + logD("Significant selection occurred, hiding keyboard") + hideKeyboard() + } + } else { + binding.searchToolbar.setVisible(R.id.search_normal_toolbar) } } diff --git a/app/src/main/java/org/oxycblt/auxio/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-v23/ui_item_ripple.xml b/app/src/main/res/drawable-v23/ui_item_ripple.xml index f8f2d8917..8f0d43cfb 100644 --- a/app/src/main/res/drawable-v23/ui_item_ripple.xml +++ b/app/src/main/res/drawable-v23/ui_item_ripple.xml @@ -1,6 +1,5 @@ - - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_edit_24.xml b/app/src/main/res/drawable/ic_edit_24.xml new file mode 100644 index 000000000..9ce54759b --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_save_24.xml b/app/src/main/res/drawable/ic_save_24.xml new file mode 100644 index 000000000..4fc73a9f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_save_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/sel_item_ripple_bg.xml b/app/src/main/res/drawable/sel_selection_bg.xml similarity index 100% rename from app/src/main/res/drawable/sel_item_ripple_bg.xml rename to app/src/main/res/drawable/sel_selection_bg.xml diff --git a/app/src/main/res/drawable/ui_item_bg.xml b/app/src/main/res/drawable/ui_item_bg.xml new file mode 100644 index 000000000..fb0a9dec3 --- /dev/null +++ b/app/src/main/res/drawable/ui_item_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_item_ripple.xml b/app/src/main/res/drawable/ui_item_ripple.xml index 03fd102f4..10aa281e7 100644 --- a/app/src/main/res/drawable/ui_item_ripple.xml +++ b/app/src/main/res/drawable/ui_item_ripple.xml @@ -1,5 +1,4 @@ - \ No newline at end of file diff --git a/app/src/main/res/layout/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"> - - + + + + tools:listitem="@layout/item_editable_song" /> - - + + + diff --git a/app/src/main/res/layout/item_album_song.xml b/app/src/main/res/layout/item_album_song.xml index 7a0e7ece7..2505dcf32 100644 --- a/app/src/main/res/layout/item_album_song.xml +++ b/app/src/main/res/layout/item_album_song.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/ui_item_ripple" + android:background="@drawable/ui_item_bg" android:paddingStart="@dimen/spacing_medium" android:paddingTop="@dimen/spacing_mid_medium" android:paddingEnd="@dimen/spacing_mid_medium" diff --git a/app/src/main/res/layout/item_edit_header.xml b/app/src/main/res/layout/item_edit_header.xml new file mode 100644 index 000000000..80659deca --- /dev/null +++ b/app/src/main/res/layout/item_edit_header.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_queue_song.xml b/app/src/main/res/layout/item_editable_song.xml similarity index 81% rename from app/src/main/res/layout/item_queue_song.xml rename to app/src/main/res/layout/item_editable_song.xml index 60cdba6bb..93fe6f0de 100644 --- a/app/src/main/res/layout/item_queue_song.xml +++ b/app/src/main/res/layout/item_editable_song.xml @@ -18,7 +18,7 @@ android:layout_height="wrap_content" android:layout_gravity="end|center_vertical" android:layout_marginEnd="@dimen/spacing_small" - android:contentDescription="@string/desc_clear_queue_item" + android:contentDescription="@string/desc_remove_song" android:padding="@dimen/spacing_medium" android:src="@drawable/ic_delete_24" app:tint="?attr/colorOnError" /> @@ -32,7 +32,7 @@ android:id="@+id/interact_body" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?attr/selectableItemBackground"> + android:background="@drawable/ui_item_ripple"> + + diff --git a/app/src/main/res/layout/item_parent.xml b/app/src/main/res/layout/item_parent.xml index f868d01aa..7b9a316db 100644 --- a/app/src/main/res/layout/item_parent.xml +++ b/app/src/main/res/layout/item_parent.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/ui_item_ripple" + android:background="@drawable/ui_item_bg" android:paddingStart="@dimen/spacing_medium" android:paddingTop="@dimen/spacing_mid_medium" android:paddingEnd="@dimen/spacing_mid_medium" diff --git a/app/src/main/res/layout/item_song.xml b/app/src/main/res/layout/item_song.xml index 570aab4cc..9f6d403f7 100644 --- a/app/src/main/res/layout/item_song.xml +++ b/app/src/main/res/layout/item_song.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/ui_item_ripple" + android:background="@drawable/ui_item_bg" android:paddingStart="@dimen/spacing_medium" android:paddingTop="@dimen/spacing_mid_medium" android:paddingEnd="@dimen/spacing_mid_medium" diff --git a/app/src/main/res/layout/item_sort_header.xml b/app/src/main/res/layout/item_sort_header.xml index 7f2deab47..ef24e6d6b 100644 --- a/app/src/main/res/layout/item_sort_header.xml +++ b/app/src/main/res/layout/item_sort_header.xml @@ -1,5 +1,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_edit_actions.xml b/app/src/main/res/menu/menu_edit_actions.xml new file mode 100644 index 000000000..10ac3d9ef --- /dev/null +++ b/app/src/main/res/menu/menu_edit_actions.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playback.xml b/app/src/main/res/menu/menu_playback.xml index 601b54b68..5dca5f5dd 100644 --- a/app/src/main/res/menu/menu_playback.xml +++ b/app/src/main/res/menu/menu_playback.xml @@ -17,6 +17,9 @@ + + + + + + + + \ No newline at end of file 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..c8701ec78 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 Уключыце або выключыце перамешванне - Выдаліць гэтую песню з чаргі + Выдаліць гэтую песню з чаргі Перамяшаць усе песні Спыніць прайграванне Адкрыйце чаргу @@ -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 4956c0358..912c15502 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 @@ -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 9b4f49f1c..e52fc3132 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 @@ -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 0dc0c61bf..adcafbaf7 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 @@ -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-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..698bb0aa4 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 @@ -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-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..d9fa1243e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -94,8 +94,8 @@ Cambia modalità ripetizione Attiva o disattiva mescolamento Mescola tutte le canzoni - Rimuove questa canzone della coda - Muove questa canzone della coda + Rimuove questa canzone della coda + Muove questa canzone della coda Muove questa scheda Cancella la query di ricerca Rimuovi cartella @@ -239,7 +239,7 @@ Attenzione: potrebbero verificarsi degli errori nella interpretazione di alcuni tag con valori multipli. Puoi risolvere aggiungendo come prefisso la barra rovesciata (\\) ai separatori indesiderati. E commerciale (&) Raccolte live - Raccolte remix + Raccolta di remix Mixes Mix Alta qualità @@ -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 686f03f87..3670dcba1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -8,7 +8,7 @@ 曲の長さ 現在の再生状態を保存 このタブを移動 - この再生待ちの曲を移動 + この再生待ちの曲を移動 日付けがありません すべての曲 @@ -43,7 +43,7 @@ 前の曲にスキップ前に曲を巻き戻す 音楽フォルダ プラス (+) - リミックスオムニバス + リミックスコンピレーション DJミックス DJミックス ディスク @@ -91,7 +91,7 @@ 再生状態を復元できません トラック %d 再生またはポーズ - 再生待ちの曲を除去 + 再生待ちの曲を除去 フォルダを除去 Auxio アイコン アルバムカバー @@ -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 4437eb3b0..fd54b0717 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -107,8 +107,8 @@ 반복 방식 변경 무작위 재생 켜기 또는 끄기 모든 곡 무작위 재생 - 이 대기열의 곡 제거 - 이 대기열의 곡 이동 + 이 대기열의 곡 제거 + 이 대기열의 곡 이동 이 탭 이동 검색 기록 삭제 폴더 제거 @@ -174,7 +174,7 @@ %d Hz 믹스 라이브 컴필레이션 - 리믹스 컴필레이션 + 리믹스 편집 믹스 이퀄라이저 셔플 @@ -278,4 +278,10 @@ 이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함) 없음 새 재생 목록 만들기 + 새 재생목록 + 재생목록에 추가 + 생성된 재생목록 + 재생목록에 추가됨 + 재생목록 %d + 노래 없음 \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 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..5754e7e38 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ą… @@ -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-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..072da2d2d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -93,8 +93,8 @@ Режим повтора Перемешивание Перемешать все треки - Удалить трек из очереди - Переместить трек в очереди + Удалить трек из очереди + Переместить трек в очереди Переместить вкладку Очистить поисковый запрос Удалить папку @@ -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-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..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 db6995078..1020ae1eb 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 @@ 更改重复播放模式 开启或关闭随机播放模式 随机播放所有曲目 - 移除队列曲目 - 移动队列曲目 + 移除队列曲目 + 移动队列曲目 移动该标签 清除搜索队列 移除文件夹 @@ -276,4 +276,18 @@ 排序时忽略冠词 按名称排序时忽略类似“the”这样的冠词(对英文歌曲的效果最好) 创建新的播放列表 + 新建播放列表 + 播放列表 %d + 已创建播放列表 + 添加到播放列表 + 已添加到播放列表 + 无歌曲 + 删除 + 删除播放列表? + 删除 %s 吗?此操作无法撤销。 + 重命名 + 重命名播放列表 + 已重命名播放列表 + 已删除播放列表 + 编辑 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b7232962..1e65cc7cc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,7 @@ Rename playlist Delete Delete playlist? + Edit Search @@ -312,8 +313,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 @@ -335,6 +336,7 @@ No track No songs No music playing + There\'s nothing here yet 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..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,19 +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 deletePlaylist(playlist: Playlist) { + override suspend fun renamePlaylist(playlist: Playlist, name: String) { throw NotImplementedError() } - override fun addToPlaylist(songs: List, playlist: Playlist) { + override suspend fun deletePlaylist(playlist: Playlist) { throw NotImplementedError() } - override fun renamePlaylist(playlist: Playlist, name: String) { + override suspend fun addToPlaylist(songs: List, playlist: Playlist) { + throw NotImplementedError() + } + + override suspend 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() } 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 {