Merge branch 'dev' into feature/share

This commit is contained in:
Alexander Capehart 2023-05-21 19:14:55 +00:00 committed by GitHub
commit 7e739061d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 1769 additions and 991 deletions

View file

@ -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

View file

@ -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'

View file

@ -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
-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

View file

@ -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.

View file

@ -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 */

View file

@ -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() {

View file

@ -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

View file

@ -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<Music>) {
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)
}
}
}

View file

@ -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<Music>) {
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)
}
}
}

View file

@ -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<Toolbar>(R.id.detail_toolbar)
val toolbar = findViewById<Toolbar>(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.

View file

@ -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<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?>
@ -158,14 +160,13 @@ constructor(
val playlistInstructions: Event<UpdateInstructions>
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<List<Song>?>(null)
/**
* The new playlist songs created during the current editing session. Null if no editing session
* is occurring.
*/
val editedPlaylist: StateFlow<List<Song>?>
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<Item>()
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
}

View file

@ -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<Music>) {
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)
}
}
}

View file

@ -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<Song, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Song> {
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<Song>?) {
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<Music>) {
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)
}
}

View file

@ -48,6 +48,13 @@ abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder
*/
fun setParent(parent: T) {
currentParent = parent
rebindParent()
}
/**
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
*/
protected fun rebindParent() {
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
}

View file

@ -25,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
@ -38,11 +39,27 @@ import org.oxycblt.auxio.util.inflater
*/
class PlaylistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
private var editedPlaylist: List<Song>? = 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<Song>?) {
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<Song>?,
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() }
}
}

View file

@ -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)

View file

@ -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<Song>) :
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<Any>
) {
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<Song>, 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<Item>() {
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<EditHeader>() {
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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<Music>) {
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)
}
}

View file

@ -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)
}
}

View file

@ -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<Playlist, FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider,

View file

@ -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<Tab>) :
class TabAdapter(private val listener: EditClickListListener<Tab>) :
RecyclerView.Adapter<TabViewHolder>() {
/** The current array of [Tab]s. */
var tabs = arrayOf<Tab>()
@ -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<Tab>) {
fun bind(tab: Tab, listener: EditClickListListener<Tab>) {
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
binding.tabCheckBox.apply {
// Update the CheckBox name to align with the mode

View file

@ -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<DialogTabsBinding>(), EditableListListener<Tab> {
ViewBindingDialogFragment<DialogTabsBinding>(), EditClickListListener<Tab> {
private val tabAdapter = TabAdapter(this)
private var touchHelper: ItemTouchHelper? = null
@Inject lateinit var homeSettings: HomeSettings

View file

@ -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))

View file

@ -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

View file

@ -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<Song>? = 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<Song>, 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
}
/**

View file

@ -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<Song> {
override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}"
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
Keyer<List<Song>> {
override fun key(data: List<Song>, options: Options) =
"${coverExtractor.computeAlbumOrdering(data).hashCode()}"
}
class ParentKeyer @Inject constructor() : Keyer<MusicParent> {
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<Song> {
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<Album> {
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<Song>,
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<Artist> {
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<List<Song>> {
override fun create(data: List<Song>, 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<Genre> {
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<Playlist> {
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 <T : Any, R : Any> Collection<T>.mapAtMostNotNull(
n: Int,
transform: (T) -> R?
): List<R> {
val until = min(size, n)
val out = mutableListOf<R>()
for (item in this) {
if (out.size >= until) {
break
}
// Still have more data we can transform.
transform(item)?.let(out::add)
}
return out
}

View file

@ -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<Song>, size: Size): FetchResult? {
val albums = computeAlbumOrdering(songs)
val streams = mutableListOf<InputStream>()
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<Song>): Collection<Album> =
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<InputStream>, 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
}
}

View file

@ -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())

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<InputStream>,
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
}
}

View file

@ -50,11 +50,11 @@ interface ClickableListListener<in T> {
}
/**
* An extension of [ClickableListListener] that enables list editing functionality.
* A listener for lists that can be edited.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface EditableListListener<in T> : ClickableListListener<T> {
interface EditableListListener {
/**
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
*
@ -62,6 +62,29 @@ interface EditableListListener<in T> : ClickableListListener<T> {
*/
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<in T> : ClickableListListener<T>, EditableListListener {
/**
* Binds this instance to a list item.
*
@ -78,13 +101,7 @@ interface EditableListListener<in T> : ClickableListListener<T> {
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)
}
}

View file

@ -206,19 +206,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
*/
fun getPlaylistComparator(direction: Direction): Comparator<Playlist>? = 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

View file

@ -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<T>(
}
is UpdateInstructions.Remove -> {
currentList = newList
updateCallback.onRemoved(instructions.at, 1)
updateCallback.onRemoved(instructions.at, instructions.size)
callback?.invoke()
}
is UpdateInstructions.Diff,

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -40,20 +40,13 @@ abstract class SelectionFragment<VB : ViewBinding> :
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}

View file

@ -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() }

View file

@ -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<Song>)
suspend fun createPlaylist(name: String, songs: List<Song>)
/**
* 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<Song>, playlist: Playlist)
suspend fun addToPlaylist(songs: List<Song>, 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<Song>)
/**
* Request that a music loading operation is started by the current [IndexingWorker]. Does
@ -211,12 +219,12 @@ constructor(
) : MusicRepository {
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
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<Song>) {
val userLibrary = userLibrary ?: return
override suspend fun createPlaylist(name: String, songs: List<Song>) {
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<Song>, playlist: Playlist) {
val userLibrary = userLibrary ?: return
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
val userLibrary = synchronized(this) { userLibrary ?: return }
userLibrary.addToPlaylist(playlist, songs)
notifyUserLibraryChange()
}
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
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<DeviceLibrary>()
val deviceLibraryJob =

View file

@ -63,8 +63,6 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
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)

View file

@ -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<Song> = 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<Song>, playlist: Playlist? = null) {
if (playlist != null) {
musicRepository.addToPlaylist(songs, playlist)
viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) }
} else {
_songsToAdd.put(songs)
}

View file

@ -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()

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RawPlaylist>
}

View file

@ -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<PlaylistSong>
)
/**
* 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
)

View file

@ -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<DeviceLibrary>): MutableUserLibrary
suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): 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<Song>)
suspend fun createPlaylist(name: String, songs: List<Song>)
/**
* 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<Song>)
suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>)
/**
* 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<Song>)
}
class UserLibraryFactoryImpl
@Inject
constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) :
UserLibrary.Factory {
override suspend fun read(deviceLibrary: Channel<DeviceLibrary>): MutableUserLibrary =
UserLibraryImpl(playlistDao, deviceLibrary.receive(), musicSettings)
override suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): 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<Music.UID, PlaylistImpl>()
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<Music.UID, PlaylistImpl>,
private val musicSettings: MusicSettings
) : MutableUserLibrary {
private val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
override val playlists: List<Playlist>
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<Song>) {
override suspend fun createPlaylist(name: String, songs: List<Song>) {
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<Song>) {
override suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
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<Song>) {
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) })
}
}

View file

@ -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()

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<RawPlaylist>
/**
* 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<PlaylistSong>) {
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<PlaylistSong>) {
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<PlaylistSong>)
/** Internal, do not use. */
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertRefs(refs: List<PlaylistSongCrossRef>)
/** Internal, do not use. */
@Query("DELETE FROM PlaylistSongCrossRef where playlistUid = :playlistUid")
suspend fun deleteRefs(playlistUid: Music.UID)
}

View file

@ -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<Intent>? = 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(

View file

@ -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)
}
/**

View file

@ -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))
}
/**

View file

@ -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<Song>) :
class QueueAdapter(private val listener: EditClickListListener<Song>) :
FlexibleListAdapter<Song, QueueSongViewHolder>(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<Song>) :
}
/**
* 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<Song>) {
listener.bind(song, this, bodyView, binding.songDragHandle)
fun bind(song: Song, listener: EditClickListListener<Song>) {
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

View file

@ -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
}

View file

@ -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<FragmentQueueBinding>(), EditableListListener<Song> {
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditClickListListener<Song> {
private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by activityViewModels()
private val queueAdapter = QueueAdapter(this)

View file

@ -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
}

View file

@ -81,7 +81,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
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<Music, FragmentSearchBinding>() {
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<Music, FragmentSearchBinding>() {
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<Music, FragmentSearchBinding>() {
private fun updateSelection(selected: List<Music>) {
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?attr/colorSurface" />
<item android:drawable="@drawable/sel_item_ripple_bg" />
<item android:drawable="@drawable/sel_selection_bg" />
<item android:drawable="?attr/selectableItemBackground" />
</layer-list>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L715,76L884,245L772,357ZM120,840L120,670L544,246L714,416L290,840L120,840ZM573,387L545,359L545,359L601,415L601,415L573,387Z"/>
</vector>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="@color/sel_activatable_icon">
<path
android:fillColor="@android:color/white"
android:pathData="M840,280L840,840L120,840L120,120L680,120L840,280ZM760,314L646,200L200,200L200,760L760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760L200,760L200,200L200,200L200,314Z"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?attr/colorSurface" />
<item android:drawable="@drawable/ui_item_ripple" />
</layer-list>

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?attr/colorSurface" />
<item android:drawable="?attr/selectableItemBackground" />
</layer-list>

View file

@ -13,19 +13,38 @@
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/detail_recycler">
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay
android:id="@+id/detail_selection_toolbar"
<org.oxycblt.auxio.ui.MultiToolbar
android:id="@+id/detail_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/detail_toolbar"
android:id="@+id/detail_normal_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_back_24" />
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/detail_selection_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_selection_actions" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/detail_edit_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_edit_actions" />
</org.oxycblt.auxio.ui.MultiToolbar>
</org.oxycblt.auxio.detail.DetailAppBarLayout>

View file

@ -12,20 +12,29 @@
android:id="@+id/home_appbar"
style="@style/Widget.Auxio.AppBarLayout">
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay
android:id="@+id/home_selection_toolbar"
<org.oxycblt.auxio.ui.MultiToolbar
android:id="@+id/home_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/home_toolbar"
android:id="@+id/home_normal_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlways"
app:menu="@menu/menu_home"
app:title="@string/info_app_name" />
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/home_selection_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_selection_actions" />
</org.oxycblt.auxio.ui.MultiToolbar>
<com.google.android.material.tabs.TabLayout
android:id="@+id/home_tabs"

View file

@ -10,7 +10,7 @@
style="@style/Widget.Auxio.RecyclerView.Linear"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="@layout/item_queue_song" />
tools:listitem="@layout/item_editable_song" />
<com.google.android.material.divider.MaterialDivider
android:id="@+id/queue_divider"

View file

@ -12,13 +12,13 @@
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/search_recycler">
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay
android:id="@+id/search_selection_toolbar"
<org.oxycblt.auxio.ui.MultiToolbar
android:id="@+id/search_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/search_toolbar"
android:id="@+id/search_normal_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/menu_search"
@ -49,7 +49,16 @@
</com.google.android.material.appbar.MaterialToolbar>
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/search_selection_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:navigationIcon="@drawable/ic_close_24"
app:menu="@menu/menu_selection_actions" />
</org.oxycblt.auxio.ui.MultiToolbar>
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>

View file

@ -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"

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:background="?attr/colorSurface"
android:orientation="horizontal"
android:layout_height="wrap_content">
<TextView
android:id="@+id/header_title"
style="@style/Widget.Auxio.TextView.Header"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium"
app:layout_constraintEnd_toStartOf="@+id/header_button"
app:layout_constraintStart_toStartOf="parent"
tools:text="Songs" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/header_edit"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium"
android:contentDescription="@string/lbl_edit"
app:icon="@drawable/ic_edit_24"
app:layout_constraintEnd_toEndOf="parent" />
<!-- <org.oxycblt.auxio.ui.RippleFixMaterialButton-->
<!-- android:id="@+id/header_sort"-->
<!-- style="@style/Widget.Auxio.Button.Icon.Small"-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_marginEnd="@dimen/spacing_mid_medium"-->
<!-- android:contentDescription="@string/lbl_cancel"-->
<!-- app:icon="@drawable/ic_sort_24"-->
<!-- app:layout_constraintEnd_toEndOf="parent" />-->
</LinearLayout>

View file

@ -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">
<org.oxycblt.auxio.image.ImageGroup
android:id="@+id/song_album_cover"
@ -79,12 +79,25 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_mid_medium"
android:contentDescription="@string/desc_queue_handle"
android:contentDescription="@string/desc_song_handle"
app:icon="@drawable/ic_handle_24"
app:layout_constraintBottom_toBottomOf="@+id/song_album_cover"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/song_album_cover" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/song_menu"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/desc_song_handle"
android:visibility="gone"
app:icon="@drawable/ic_more_24"
app:layout_constraintBottom_toBottomOf="@+id/song_drag_handle"
app:layout_constraintEnd_toEndOf="@+id/song_drag_handle"
app:layout_constraintStart_toStartOf="@id/song_drag_handle"
app:layout_constraintTop_toTopOf="@+id/song_drag_handle" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View file

@ -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"

View file

@ -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"

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
@ -20,7 +19,7 @@
tools:text="Songs" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/header_button"
android:id="@+id/header_sort"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View file

@ -12,4 +12,7 @@
<item
android:id="@+id/action_share"
android:title="@string/lbl_share" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
</menu>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_save"
android:title="@string/lbl_save"
app:showAsAction="always"
android:icon="@drawable/ic_save_24" />
</menu>

View file

@ -17,6 +17,9 @@
<item
android:id="@+id/action_share"
android:title="@string/lbl_share" />
<item
android:id="@+id/action_playlist_add"
android:title="@string/lbl_playlist_add" />
<item
android:id="@+id/action_song_detail"
android:title="@string/lbl_song_detail"

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_play_next"
android:title="@string/lbl_play_next" />
<item
android:id="@+id/action_queue_add"
android:title="@string/lbl_queue_add" />
<item
android:id="@+id/action_go_artist"
android:title="@string/lbl_go_artist" />
<item
android:id="@+id/action_go_album"
android:title="@string/lbl_go_album" />
<item
android:id="@+id/action_song_detail"
android:title="@string/lbl_song_detail" />
</menu>

View file

@ -89,8 +89,8 @@
<string name="desc_change_repeat">تغيير وضع التكرار</string>
<string name="desc_shuffle">تشغيل او اطفاء الخلط</string>
<string name="desc_shuffle_all">خلط جميع الاغاني</string>
<string name="desc_clear_queue_item">إزالة اغنية من الطابور</string>
<string name="desc_queue_handle">نقل اغنية من الطابور</string>
<string name="desc_remove_song">إزالة اغنية من الطابور</string>
<string name="desc_song_handle">نقل اغنية من الطابور</string>
<string name="desc_tab_handle">تحريك التبويت</string>
<string name="desc_clear_search">إزالة كلمة البحث</string>
<string name="desc_music_dir_delete">إزالة المجلد المستبعد</string>

View file

@ -144,7 +144,7 @@
<string name="err_bad_dir">Гэтая папка не падтрымліваецца</string>
<string name="err_did_not_restore">Немагчыма аднавіць стан</string>
<string name="desc_track_number">Кампазіцыя %d</string>
<string name="desc_queue_handle">Перамясціць песню ў чаргу</string>
<string name="desc_song_handle">Перамясціць песню ў чаргу</string>
<string name="err_no_app">Не знойдзена прыкладання, якое можа справіцца з гэтай задачай</string>
<string name="desc_play_pause">Прайграванне або прыпыненне</string>
<string name="err_did_not_save">Немагчыма захаваць стан</string>
@ -153,7 +153,7 @@
<string name="desc_change_repeat">Змяніць рэжым паўтору</string>
<string name="desc_auxio_icon">Значок Auxio</string>
<string name="desc_shuffle">Уключыце або выключыце перамешванне</string>
<string name="desc_clear_queue_item">Выдаліць гэтую песню з чаргі</string>
<string name="desc_remove_song">Выдаліць гэтую песню з чаргі</string>
<string name="desc_shuffle_all">Перамяшаць усе песні</string>
<string name="desc_exit">Спыніць прайграванне</string>
<string name="desc_queue_bar">Адкрыйце чаргу</string>
@ -278,4 +278,8 @@
<string name="desc_new_playlist">Стварыце новы плэйліст</string>
<string name="fmt_def_playlist">Плэйліст %d</string>
<string name="lbl_new_playlist">Новы плэйліст</string>
<string name="lbl_playlist_add">Дадаць у плэйліст</string>
<string name="lng_playlist_created">Плэйліст створаны</string>
<string name="lng_playlist_added">Паведамленні ў плэйліст</string>
<string name="def_song_count">Без трэкаў</string>
</resources>

View file

@ -109,8 +109,8 @@
<string name="desc_change_repeat">Změnit režim opakování</string>
<string name="desc_shuffle">Vypnout nebo zapnout náhodné přehrávání</string>
<string name="desc_shuffle_all">Náhodně přehrávat vše</string>
<string name="desc_clear_queue_item">Odebrat tuto skladbu z fronty</string>
<string name="desc_queue_handle">Přesunout tuto skladbu ve frontě</string>
<string name="desc_remove_song">Odebrat tuto skladbu z fronty</string>
<string name="desc_song_handle">Přesunout tuto skladbu ve frontě</string>
<string name="desc_tab_handle">Přesunout tuto kartu</string>
<string name="desc_clear_search">Vymazat hledání</string>
<string name="desc_music_dir_delete">Odebrat složku</string>
@ -287,4 +287,17 @@
<string name="set_intelligent_sorting_desc">Ignorovat slova jako „the“ při řazení podle názvu (funguje nejlépe u hudby v angličtině)</string>
<string name="lbl_none">Žádné</string>
<string name="desc_new_playlist">Vytvořit nový playlist</string>
<string name="lbl_playlist_add">Přidat do seznamu skladeb</string>
<string name="lng_playlist_added">Přidáno do seznamu skladeb</string>
<string name="lng_playlist_created">Seznam skladeb vytvořen</string>
<string name="def_song_count">Žádné skladby</string>
<string name="lbl_new_playlist">Nový seznam skladeb</string>
<string name="fmt_def_playlist">Seznam skladeb %d</string>
<string name="lbl_delete">Odstranit</string>
<string name="lbl_confirm_delete_playlist">Odstranit seznam skladeb\?</string>
<string name="fmt_deletion_info">Odstranit seznam %s\? Tato akce je nevratná.</string>
<string name="lbl_rename">Přejmenovat</string>
<string name="lng_playlist_renamed">Seznam skladeb přejmenován</string>
<string name="lng_playlist_deleted">Seznam skladeb odstraněn</string>
<string name="lbl_rename_playlist">Přejmenovat seznam skladeb</string>
</resources>

View file

@ -125,7 +125,7 @@
<string name="set_repeat_pause">Pause bei Wiederholung</string>
<string name="set_repeat_pause_desc">Pausieren, wenn ein Song wiederholt wird</string>
<string name="desc_shuffle">Zufällig an- oder ausschalten</string>
<string name="desc_queue_handle">Lied in der Warteschlange verschieben</string>
<string name="desc_song_handle">Lied in der Warteschlange verschieben</string>
<string name="desc_music_dir_delete">Verzechnis entfernen</string>
<string name="desc_no_cover">Albumcover</string>
<string name="def_playback">Keine Musik wird gespielt</string>
@ -133,7 +133,7 @@
<string name="set_lib_tabs_desc">Sichtbarkeit und Ordnung der Bibliotheksregisterkarten ändern</string>
<string name="lbl_name">Name</string>
<string name="desc_shuffle_all">Alle Lieder zufällig</string>
<string name="desc_clear_queue_item">Lied in der Warteschlange löschen</string>
<string name="desc_remove_song">Lied in der Warteschlange löschen</string>
<string name="desc_tab_handle">Tab versetzen</string>
<string name="def_artist">Unbekannter Künstler</string>
<string name="lbl_duration">Dauer</string>
@ -271,11 +271,24 @@
<string name="set_state">Persistenz</string>
<string name="set_replay_gain">Lautstärkeanpassung ReplayGain</string>
<string name="lbl_sort_dec">Absteigend</string>
<string name="desc_playlist_image">Playlist-Bild für %s</string>
<string name="desc_playlist_image">Wiedergabelistenbild für %s</string>
<string name="lbl_playlist">Wiedergabeliste</string>
<string name="lbl_playlists">Wiedergabelisten</string>
<string name="set_intelligent_sorting">Artikel beim Sortieren ignorieren</string>
<string name="set_intelligent_sorting_desc">Wörter wie „the“ ignorieren (funktioniert am besten mit englischsprachiger Musik)</string>
<string name="lbl_none">Keine</string>
<string name="desc_new_playlist">Neue Wiedergabeliste erstellen</string>
<string name="lbl_new_playlist">Neue Wiedergabeliste</string>
<string name="lng_playlist_added">Zur Wiedergabeliste hinzugefügt</string>
<string name="lbl_playlist_add">Zur Wiedergabeliste hinzufügen</string>
<string name="lng_playlist_created">Wiedergabeliste erstellt</string>
<string name="lbl_delete">Löschen</string>
<string name="lbl_confirm_delete_playlist">Wiedergabeliste löschen\?</string>
<string name="def_song_count">Keine Lieder</string>
<string name="fmt_def_playlist">Wiedergabeliste %d</string>
<string name="fmt_deletion_info">%s löschen\? Dies kann nicht rückgängig gemacht werden.</string>
<string name="lbl_rename">Umbenennen</string>
<string name="lbl_rename_playlist">Wiedergabeliste umbenennen</string>
<string name="lng_playlist_renamed">Wiedergabeliste umbenannt</string>
<string name="lng_playlist_deleted">Wiedergabeliste gelöscht</string>
</resources>

View file

@ -91,8 +91,8 @@
<string name="desc_change_repeat">Cambiar modo de repetición</string>
<string name="desc_shuffle">Act/des mezcla</string>
<string name="desc_shuffle_all">Mezclar todo</string>
<string name="desc_clear_queue_item">Quitar canción de la cola</string>
<string name="desc_queue_handle">Mover canción en la cola</string>
<string name="desc_remove_song">Quitar canción de la cola</string>
<string name="desc_song_handle">Mover canción en la cola</string>
<string name="desc_tab_handle">Mover pestaña</string>
<string name="desc_clear_search">Borrar historial de búsqueda</string>
<string name="desc_music_dir_delete">Quitar carpeta</string>
@ -282,4 +282,18 @@
<string name="set_intelligent_sorting">Ignorar artículos al ordenar</string>
<string name="set_intelligent_sorting_desc">Ignorar palabras como \"the\" al ordenar por nombre (funciona mejor con música en inglés)</string>
<string name="desc_new_playlist">Crear una nueva lista de reproducción</string>
<string name="lbl_new_playlist">Nueva lista de reproducción</string>
<string name="fmt_def_playlist">Lista de reproducción %d</string>
<string name="lbl_playlist_add">Agregar a la lista de reproducción</string>
<string name="lng_playlist_added">Agregado a la lista de reproducción</string>
<string name="lng_playlist_created">Lista de reproducción creada</string>
<string name="def_song_count">No hay canciones</string>
<string name="lbl_delete">Borrar</string>
<string name="lbl_rename">Cambiar el nombre</string>
<string name="lbl_rename_playlist">Cambiar el nombre de la lista de reproducción</string>
<string name="lng_playlist_renamed">Lista de reproducción renombrada</string>
<string name="lng_playlist_deleted">Lista de reproducción borrada</string>
<string name="fmt_deletion_info">¿Borrar %s\? Esto no se puede deshacer.</string>
<string name="lbl_confirm_delete_playlist">¿Borrar la lista de reproducción\?</string>
<string name="lbl_edit">Editar</string>
</resources>

View file

@ -203,8 +203,8 @@
<string name="cdc_aac">Advanced Audio Coding (AAC)</string>
<string name="cdc_flac">Free Lossless Audio Codec (FLAC)</string>
<string name="clr_red">Vermello</string>
<string name="desc_clear_queue_item">Quitar esta canción da cola</string>
<string name="desc_queue_handle">Mover está canción na cola</string>
<string name="desc_remove_song">Quitar esta canción da cola</string>
<string name="desc_song_handle">Mover está canción na cola</string>
<string name="desc_tab_handle">Mover esta pestana</string>
<string name="clr_pink">Rosa</string>
<string name="clr_purple">Morado</string>

View file

@ -107,8 +107,8 @@
<string name="desc_track_number">Zvučni zapis %d</string>
<string name="desc_shuffle">Omogućite ili onemogućite miješanje</string>
<string name="desc_shuffle_all">Izmiješaj sve pjesme</string>
<string name="desc_clear_queue_item">Ukoni ovu pjesmu iz popisa pjesama</string>
<string name="desc_queue_handle">Premjesti ovu pjesmu u popisu pjesama</string>
<string name="desc_remove_song">Ukoni ovu pjesmu iz popisa pjesama</string>
<string name="desc_song_handle">Premjesti ovu pjesmu u popisu pjesama</string>
<string name="desc_tab_handle">Pomakni ovu pločicu</string>
<string name="desc_clear_search">Izbriši pretražene pojmove</string>
<string name="desc_music_dir_delete">Ukloni mapu</string>
@ -214,7 +214,7 @@
<string name="set_separators_comma">Zarez (,)</string>
<string name="set_separators_and">Ampersand (&amp;)</string>
<string name="lbl_compilation_live">Kompilacija uživo</string>
<string name="lbl_compilation_remix">Kompilacije remiksa</string>
<string name="lbl_compilation_remix">Kompilacija remiksa</string>
<string name="lbl_mixes">Kompilacije</string>
<string name="set_separators">Znakovi odjeljivanja vrijednosti</string>
<string name="desc_exit">Prekini reprodukciju</string>
@ -273,4 +273,18 @@
<string name="set_intelligent_sorting">Pametno razvrstavanje</string>
<string name="set_intelligent_sorting_desc">Ispravno razvrstaj imena koja počinju brojevima ili riječima poput „the” (najbolje radi s glazbom na engleskom jeziku)</string>
<string name="desc_new_playlist">Stvori novi popis pjesama</string>
<string name="lbl_new_playlist">Novi popis pjesama</string>
<string name="lbl_playlist_add">Dodaj u popis pjesama</string>
<string name="def_song_count">Nema pjesama</string>
<string name="lbl_delete">Izbriši</string>
<string name="fmt_def_playlist">Popis pjesama %d</string>
<string name="lbl_rename">Preimenuj</string>
<string name="lbl_rename_playlist">Preimenuj popis pjesama</string>
<string name="lbl_confirm_delete_playlist">Izbrisati popis pjesama\?</string>
<string name="lng_playlist_created">Popis pjesama je stvoren</string>
<string name="lng_playlist_renamed">Popis pjesama je preimenovan</string>
<string name="lng_playlist_deleted">Popis pjesama je izbrisan</string>
<string name="lng_playlist_added">Dodano u popis pjesama</string>
<string name="lbl_edit">Uredi</string>
<string name="fmt_deletion_info">Izbrisati %s\? To je nepovratna radnja.</string>
</resources>

View file

@ -139,7 +139,7 @@
<string name="desc_artist_image">Gambar Artis untuk %s</string>
<string name="set_detail_song_playback_mode">Saat diputar dari keterangan item</string>
<string name="set_dirs_mode_exclude_desc">Musik <b>tidak</b> akan dimuat dari folder yang Anda tambahkan.</string>
<string name="desc_clear_queue_item">Hapus lagu antrian ini</string>
<string name="desc_remove_song">Hapus lagu antrian ini</string>
<string name="desc_clear_search">Hapus kueri pencarian</string>
<string name="set_pre_amp_without">Penyesuaian tanpa tag</string>
<string name="set_dirs">Folder musik</string>
@ -159,7 +159,7 @@
<string name="desc_auxio_icon">Ikon Auxio</string>
<string name="desc_no_cover">Sampul album</string>
<string name="desc_shuffle">Aktifkan atau nonaktifkan acak</string>
<string name="desc_queue_handle">Pindahkan lagu antrian ini</string>
<string name="desc_song_handle">Pindahkan lagu antrian ini</string>
<string name="def_playback">Tidak ada musik yang diputar</string>
<string name="cdc_ogg">Audio Ogg</string>
<string name="clr_brown">Cokelat</string>

View file

@ -94,8 +94,8 @@
<string name="desc_change_repeat">Cambia modalità ripetizione</string>
<string name="desc_shuffle">Attiva o disattiva mescolamento</string>
<string name="desc_shuffle_all">Mescola tutte le canzoni</string>
<string name="desc_clear_queue_item">Rimuove questa canzone della coda</string>
<string name="desc_queue_handle">Muove questa canzone della coda</string>
<string name="desc_remove_song">Rimuove questa canzone della coda</string>
<string name="desc_song_handle">Muove questa canzone della coda</string>
<string name="desc_tab_handle">Muove questa scheda</string>
<string name="desc_clear_search">Cancella la query di ricerca</string>
<string name="desc_music_dir_delete">Rimuovi cartella</string>
@ -239,7 +239,7 @@
<string name="set_separators_warning">Attenzione: potrebbero verificarsi degli errori nella interpretazione di alcuni tag con valori multipli. Puoi risolvere aggiungendo come prefisso la barra rovesciata (\\) ai separatori indesiderati.</string>
<string name="set_separators_and">E commerciale (&amp;)</string>
<string name="lbl_compilation_live">Raccolte live</string>
<string name="lbl_compilation_remix">Raccolte remix</string>
<string name="lbl_compilation_remix">Raccolta di remix</string>
<string name="lbl_mixes">Mixes</string>
<string name="lbl_mix">Mix</string>
<string name="set_cover_mode_quality">Alta qualità</string>
@ -281,4 +281,11 @@
<string name="set_intelligent_sorting_desc">Ignora parole come \"the\" durante l\'ordinamento per nome (funziona meglio con la musica in lingua inglese)</string>
<string name="desc_new_playlist">Crea una nuova playlist</string>
<string name="desc_playlist_image">Immagine della playlist per %s</string>
<string name="lbl_new_playlist">Nuova playlist</string>
<string name="lbl_playlist_add">Aggiungi a playlist</string>
<string name="lng_playlist_created">Playlist creata</string>
<string name="lng_playlist_added">Aggiunto alla playlist</string>
<string name="def_song_count">Niente canzoni</string>
<string name="fmt_def_playlist">Playlist %d</string>
<string name="lbl_none">Nessuno</string>
</resources>

View file

@ -8,7 +8,7 @@
<string name="lbl_duration">曲の長さ</string>
<string name="set_save_desc">現在の再生状態を保存</string>
<string name="desc_tab_handle">このタブを移動</string>
<string name="desc_queue_handle">この再生待ちの曲を移動</string>
<string name="desc_song_handle">この再生待ちの曲を移動</string>
<string name="def_date">日付けがありません</string>
<string name="lbl_songs"></string>
<string name="lbl_all_songs">すべての曲</string>
@ -43,7 +43,7 @@
<string name="set_rewind_prev_desc">前の曲にスキップ前に曲を巻き戻す</string>
<string name="set_dirs">音楽フォルダ</string>
<string name="set_separators_plus">プラス (+)</string>
<string name="lbl_compilation_remix">リミックスオムニバス</string>
<string name="lbl_compilation_remix">リミックスコンピレーション</string>
<string name="lbl_mixes">DJミックス</string>
<string name="lbl_mix">DJミックス</string>
<string name="lbl_disc">ディスク</string>
@ -91,7 +91,7 @@
<string name="err_did_not_restore">再生状態を復元できません</string>
<string name="desc_track_number">トラック %d</string>
<string name="desc_play_pause">再生またはポーズ</string>
<string name="desc_clear_queue_item">再生待ちの曲を除去</string>
<string name="desc_remove_song">再生待ちの曲を除去</string>
<string name="desc_music_dir_delete">フォルダを除去</string>
<string name="desc_auxio_icon">Auxio アイコン</string>
<string name="desc_no_cover">アルバムカバー</string>
@ -266,4 +266,11 @@
<string name="lbl_playlists">プレイリスト</string>
<string name="desc_playlist_image">%s のプレイリスト イメージ</string>
<string name="lbl_none">無し</string>
<string name="lbl_new_playlist">新規プレイリスト</string>
<string name="lbl_playlist_add">プレイリストに追加する</string>
<string name="lng_playlist_created">プレイリストが作成されました</string>
<string name="lng_playlist_added">プレイリストに追加されました</string>
<string name="def_song_count">曲がありません</string>
<string name="fmt_def_playlist">プレイリスト %d</string>
<string name="desc_new_playlist">新しいプレイリストを作成する</string>
</resources>

View file

@ -107,8 +107,8 @@
<string name="desc_change_repeat">반복 방식 변경</string>
<string name="desc_shuffle">무작위 재생 켜기 또는 끄기</string>
<string name="desc_shuffle_all">모든 곡 무작위 재생</string>
<string name="desc_clear_queue_item">이 대기열의 곡 제거</string>
<string name="desc_queue_handle">이 대기열의 곡 이동</string>
<string name="desc_remove_song">이 대기열의 곡 제거</string>
<string name="desc_song_handle">이 대기열의 곡 이동</string>
<string name="desc_tab_handle">이 탭 이동</string>
<string name="desc_clear_search">검색 기록 삭제</string>
<string name="desc_music_dir_delete">폴더 제거</string>
@ -174,7 +174,7 @@
<string name="fmt_sample_rate">%d Hz</string>
<string name="lbl_mix">믹스</string>
<string name="lbl_compilation_live">라이브 컴필레이션</string>
<string name="lbl_compilation_remix">리믹스 컴필레이션</string>
<string name="lbl_compilation_remix">리믹스 편집</string>
<string name="lbl_mixes">믹스</string>
<string name="lbl_equalizer">이퀄라이저</string>
<string name="lbl_shuffle_shortcut_short">셔플</string>
@ -278,4 +278,10 @@
<string name="set_intelligent_sorting_desc">이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함)</string>
<string name="lbl_none">없음</string>
<string name="desc_new_playlist">새 재생 목록 만들기</string>
<string name="lbl_new_playlist">새 재생목록</string>
<string name="lbl_playlist_add">재생목록에 추가</string>
<string name="lng_playlist_created">생성된 재생목록</string>
<string name="lng_playlist_added">재생목록에 추가됨</string>
<string name="fmt_def_playlist">재생목록 %d</string>
<string name="def_song_count">노래 없음</string>
</resources>

View file

@ -138,7 +138,7 @@
<string name="set_replay_gain_mode_dynamic">Pageidaujamas albumui, jei vienas groja</string>
<string name="err_no_app">Jokių programų nerasta, kurios galėtų atlikti šią užduotį</string>
<string name="desc_auxio_icon">„Auxio“ piktograma</string>
<string name="desc_queue_handle">Perkelti šią eilės dainą</string>
<string name="desc_song_handle">Perkelti šią eilės dainą</string>
<string name="desc_tab_handle">Perkelti šį skirtuką</string>
<string name="err_index_failed">Muzikos krovimas nepavyko</string>
<string name="err_no_perms">„Auxio“ reikia leidimo skaityti jūsų muzikos biblioteką</string>
@ -173,7 +173,7 @@
<string name="desc_clear_search">Išvalyti paieškos užklausą</string>
<string name="set_dirs_mode_exclude_desc">Muzika <b>nebus</b> įkeliama iš pridėtų aplankų jūs pridėsite.</string>
<string name="set_dirs_mode_include">Įtraukti</string>
<string name="desc_clear_queue_item">Pašalinti šią eilės dainą</string>
<string name="desc_remove_song">Pašalinti šią eilės dainą</string>
<string name="set_playback_mode_songs">Groti iš visų dainų</string>
<string name="set_playback_mode_none">Groti iš parodyto elemento</string>
<string name="set_playback_mode_album">Groti iš albumo</string>

View file

@ -65,8 +65,8 @@
<string name="def_playback">സംഗീതം കളിക്കുന്നില്ല</string>
<string name="clr_yellow">മഞ്ഞ</string>
<string name="fmt_selected">%d തിരഞ്ഞെടുത്തു</string>
<string name="desc_clear_queue_item">വരിയിലെ ഈ ഗാനം നീക്കം ചെയ്യുക</string>
<string name="desc_queue_handle">വരിയിലെ ഈ ഗാനം നീക്കുക</string>
<string name="desc_remove_song">വരിയിലെ ഈ ഗാനം നീക്കം ചെയ്യുക</string>
<string name="desc_song_handle">വരിയിലെ ഈ ഗാനം നീക്കുക</string>
<string name="lbl_reset">പുനഃസജ്ജമാക്കുക</string>
<string name="clr_brown">തവിട്ട്</string>
<string name="fmt_list">%1$s, %2$s</string>

View file

@ -162,8 +162,8 @@
<string name="set_restore_state">Afspeelstatus herstellen</string>
<string name="set_restore_desc">Herstel de eerder opgeslagen afspeelstatus (indien aanwezig)</string>
<string name="err_did_not_restore">Geen staat kan hersteld worden</string>
<string name="desc_clear_queue_item">Verwijder dit wachtrij liedje</string>
<string name="desc_queue_handle">Verplaats dit wachtrij liedje</string>
<string name="desc_remove_song">Verwijder dit wachtrij liedje</string>
<string name="desc_song_handle">Verplaats dit wachtrij liedje</string>
<string name="desc_tab_handle">Verplaats deze tab</string>
<string name="desc_no_cover">Album cover</string>
<string name="def_track">Geen tracknummer</string>

View file

@ -196,8 +196,8 @@
<string name="set_library">ਲਾਇਬ੍ਰੇਰੀ</string>
<string name="set_dirs">ਸੰਗੀਤ ਫੋਲਡਰ</string>
<string name="desc_queue_bar">ਕਤਾਰ ਖੋਲ੍ਹੋ</string>
<string name="desc_clear_queue_item">ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਹਟਾਓ</string>
<string name="desc_queue_handle">ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਮੂਵ ਕਰੋ</string>
<string name="desc_remove_song">ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਹਟਾਓ</string>
<string name="desc_song_handle">ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਮੂਵ ਕਰੋ</string>
<string name="desc_change_repeat">ਦੁਹਰਾਓ ਮੋਡ ਬਦਲੋ</string>
<string name="desc_shuffle">ਸ਼ਫਲ ਚਾਲੂ ਜਾਂ ਬੰਦ ਕਰੋ</string>
<string name="desc_shuffle_all">ਸਾਰੇ ਗੀਤਾਂ ਨੂੰ ਸ਼ਫਲ ਕਰੋ</string>

View file

@ -137,7 +137,7 @@
<string name="set_headset_autoplay_desc">Automatycznie odtwórz muzykę po podłączeniu słuchawek (może nie działać na wszystkich urządzeniach)</string>
<string name="set_reindex">Odśwież muzykę</string>
<string name="set_reindex_desc">Odśwież bibliotekę muzyczną używając tagów z pamięci cache, jeśli są dostępne</string>
<string name="desc_clear_queue_item">Usuń utwór z kolejki</string>
<string name="desc_remove_song">Usuń utwór z kolejki</string>
<string name="set_replay_gain_mode_album">Preferuj album</string>
<string name="set_observing">Automatycznie odśwież</string>
<string name="cdc_flac">FLAC</string>
@ -173,7 +173,7 @@
<string name="set_observing_desc">Automatycznie odśwież bibliotekę po wykryciu zmian (wymaga stałego powiadomienia)</string>
<string name="set_dirs_mode_exclude">Wyklucz</string>
<string name="set_dirs_mode_include">Zawrzyj</string>
<string name="desc_queue_handle">Zmień pozycję utworu w kolejce</string>
<string name="desc_song_handle">Zmień pozycję utworu w kolejce</string>
<string name="desc_tab_handle">Przesuń kartę</string>
<string name="desc_artist_image">Wizerunek wykonawcy dla %s</string>
<string name="lng_indexing">Ładuję bibliotekę muzyczną…</string>
@ -283,4 +283,10 @@
<string name="set_intelligent_sorting_desc">Ignoruj słowa takie jak „the” oraz numery w tytule podczas sortowania (działa najlepiej z utworami w języku angielskim)</string>
<string name="lbl_none">Brak</string>
<string name="desc_new_playlist">Utwórz nową playlistę</string>
<string name="lbl_new_playlist">Nowa playlista</string>
<string name="lbl_playlist_add">Dodaj do playlisty</string>
<string name="lng_playlist_created">Utworzono playlistę</string>
<string name="def_song_count">Brak utworów</string>
<string name="lng_playlist_added">Dodano do playlisty</string>
<string name="fmt_def_playlist">Playlista %d</string>
</resources>

View file

@ -123,7 +123,7 @@
<string name="desc_skip_prev">Pular para a música anterior</string>
<string name="desc_change_repeat">Alterar o modo de repetição</string>
<string name="desc_shuffle_all">Aleatorizar todas das músicas</string>
<string name="desc_clear_queue_item">Remover esta música da fila</string>
<string name="desc_remove_song">Remover esta música da fila</string>
<string name="desc_clear_search">Limpar histórico de pesquisa</string>
<string name="desc_album_cover">Capa do álbum para %s</string>
<string name="desc_tab_handle">Mover esta aba</string>
@ -147,7 +147,7 @@
<string name="cdc_mka">Áudio Matroska</string>
<string name="cdc_aac">Codificação de Audio Avançada (AAC)</string>
<string name="cdc_flac">Free Lossless Audio Codec (FLAC)</string>
<string name="desc_queue_handle">Mover esta música da fila</string>
<string name="desc_song_handle">Mover esta música da fila</string>
<string name="clr_dynamic">Dinâmico</string>
<string name="fmt_lib_total_duration">Duração total: %s</string>
<string name="fmt_indexing">Carregando sua biblioteca de músicas… (%1$d/%2$d)</string>

View file

@ -98,7 +98,7 @@
<string name="err_no_perms">O Auxio precisa de permissão para ler a sua biblioteca de músicas</string>
<string name="err_no_dirs">Sem pastas</string>
<string name="err_bad_dir">Esta pasta não é compatível</string>
<string name="desc_queue_handle">Mover esta música da fila</string>
<string name="desc_song_handle">Mover esta música da fila</string>
<string name="desc_music_dir_delete">Remover pasta</string>
<string name="lbl_compilation_remix">Compilações de remix</string>
<string name="lbl_compilation_live">Compilação ao vivo</string>
@ -195,7 +195,7 @@
<string name="set_restore_desc">Restaurar o estado de reprodução salvo anteriormente (se houver)</string>
<string name="desc_shuffle">Ativar ou desativar a reprodução aleatória</string>
<string name="desc_shuffle_all">Embaralhar todas as músicas</string>
<string name="desc_clear_queue_item">Remover esta música de fila</string>
<string name="desc_remove_song">Remover esta música de fila</string>
<string name="cdc_mka">Áudio Matroska</string>
<string name="cdc_aac">Codificação de Audio Avançada (AAC)</string>
<string name="lbl_album">Álbum</string>

View file

@ -93,8 +93,8 @@
<string name="desc_change_repeat">Режим повтора</string>
<string name="desc_shuffle">Перемешивание</string>
<string name="desc_shuffle_all">Перемешать все треки</string>
<string name="desc_clear_queue_item">Удалить трек из очереди</string>
<string name="desc_queue_handle">Переместить трек в очереди</string>
<string name="desc_remove_song">Удалить трек из очереди</string>
<string name="desc_song_handle">Переместить трек в очереди</string>
<string name="desc_tab_handle">Переместить вкладку</string>
<string name="desc_clear_search">Очистить поисковый запрос</string>
<string name="desc_music_dir_delete">Удалить папку</string>
@ -287,4 +287,8 @@
<string name="desc_new_playlist">Создать новый плейлист</string>
<string name="lbl_new_playlist">Новый плейлист</string>
<string name="fmt_def_playlist">Плейлист %d</string>
<string name="lbl_playlist_add">Добавить в плейлист</string>
<string name="def_song_count">Без треков</string>
<string name="lng_playlist_added">Добавлено в плейлист</string>
<string name="lng_playlist_created">Плейлист создан</string>
</resources>

View file

@ -1,2 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources />
<resources>
<string name="lbl_observing">Праћење музичке библиотеке</string>
<string name="lbl_retry">Покушај поново</string>
<string name="lbl_grant">Одобрити</string>
<string name="info_app_desc">Једноставан, рационалан музички плејер за android.</string>
<string name="lbl_indexer">Музика се учитава</string>
<string name="lbl_indexing">Учитавање музике</string>
</resources>

View file

@ -192,7 +192,7 @@
<string name="lbl_date_added">Eklendiği tarih</string>
<string name="lbl_album_remix">Remix albüm</string>
<string name="lbl_album_live">Canlı albüm</string>
<string name="desc_clear_queue_item">Bu şarkıyı kuyruktan kaldır</string>
<string name="desc_remove_song">Bu şarkıyı kuyruktan kaldır</string>
<string name="lbl_singles">Tekliler</string>
<string name="lbl_single">Tekli</string>
<string name="lbl_mixtape">Karışık kaset</string>
@ -254,7 +254,7 @@
<string name="set_exclude_non_music">Müzik olmayanları hariç tut</string>
<string name="err_did_not_wipe">Durum temizlenemedi</string>
<string name="set_replay_gain_mode">ReplayGain stratejisi</string>
<string name="desc_queue_handle">Bu şarkıyı kuyrukta taşı</string>
<string name="desc_song_handle">Bu şarkıyı kuyrukta taşı</string>
<string name="fmt_list">%1$s, %2$s</string>
<string name="set_content_desc">Müzik ve görüntülerin nasıl yükleneceğini denetleyin</string>
<string name="set_music">Müzik</string>

View file

@ -218,8 +218,8 @@
<string name="def_genre">Невідомий жанр</string>
<string name="desc_queue_bar">Відкрити чергу</string>
<string name="clr_yellow">Жовтий</string>
<string name="desc_queue_handle">Перемістити пісню в черзі</string>
<string name="desc_clear_queue_item">Видалити пісню з черги</string>
<string name="desc_song_handle">Перемістити пісню</string>
<string name="desc_remove_song">Видалити пісню</string>
<string name="clr_cyan">Блакитний</string>
<string name="clr_teal">Зеленувато-блакитний</string>
<string name="clr_purple">Фіолетовий</string>
@ -284,4 +284,16 @@
<string name="desc_new_playlist">Створити новий список відтворення</string>
<string name="lbl_new_playlist">Новий список відтворення</string>
<string name="fmt_def_playlist">Список відтворення %d</string>
<string name="lbl_playlist_add">Додати до списку відтворення</string>
<string name="lng_playlist_added">Додано до списку відтворення</string>
<string name="lng_playlist_created">Список відтворення створено</string>
<string name="def_song_count">Немає пісень</string>
<string name="lbl_delete">Видалити</string>
<string name="lbl_confirm_delete_playlist">Видалити список відтворення\?</string>
<string name="fmt_deletion_info">Видалити %s\? Цю дію не можна скасувати.</string>
<string name="lng_playlist_deleted">Список відтворення видалено</string>
<string name="lbl_rename">Перейменувати</string>
<string name="lbl_rename_playlist">Перейменувати список відтворення</string>
<string name="lng_playlist_renamed">Список відтворення перейменовано</string>
<string name="lbl_edit">Редагувати</string>
</resources>

View file

@ -93,8 +93,8 @@
<string name="desc_change_repeat">更改重复播放模式</string>
<string name="desc_shuffle">开启或关闭随机播放模式</string>
<string name="desc_shuffle_all">随机播放所有曲目</string>
<string name="desc_clear_queue_item">移除队列曲目</string>
<string name="desc_queue_handle">移动队列曲目</string>
<string name="desc_remove_song">移除队列曲目</string>
<string name="desc_song_handle">移动队列曲目</string>
<string name="desc_tab_handle">移动该标签</string>
<string name="desc_clear_search">清除搜索队列</string>
<string name="desc_music_dir_delete">移除文件夹</string>
@ -276,4 +276,18 @@
<string name="set_intelligent_sorting">排序时忽略冠词</string>
<string name="set_intelligent_sorting_desc">按名称排序时忽略类似“the”这样的冠词对英文歌曲的效果最好</string>
<string name="desc_new_playlist">创建新的播放列表</string>
<string name="lbl_new_playlist">新建播放列表</string>
<string name="fmt_def_playlist">播放列表 %d</string>
<string name="lng_playlist_created">已创建播放列表</string>
<string name="lbl_playlist_add">添加到播放列表</string>
<string name="lng_playlist_added">已添加到播放列表</string>
<string name="def_song_count">无歌曲</string>
<string name="lbl_delete">删除</string>
<string name="lbl_confirm_delete_playlist">删除播放列表?</string>
<string name="fmt_deletion_info">删除 %s 吗?此操作无法撤销。</string>
<string name="lbl_rename">重命名</string>
<string name="lbl_rename_playlist">重命名播放列表</string>
<string name="lng_playlist_renamed">已重命名播放列表</string>
<string name="lng_playlist_deleted">已删除播放列表</string>
<string name="lbl_edit">编辑</string>
</resources>

View file

@ -83,6 +83,7 @@
<string name="lbl_rename_playlist">Rename playlist</string>
<string name="lbl_delete">Delete</string>
<string name="lbl_confirm_delete_playlist">Delete playlist?</string>
<string name="lbl_edit">Edit</string>
<!-- Search for music -->
<string name="lbl_search">Search</string>
@ -312,8 +313,8 @@
<string name="desc_new_playlist">Create a new playlist</string>
<string name="desc_exit">Stop playback</string>
<string name="desc_clear_queue_item">Remove this queue song</string>
<string name="desc_queue_handle">Move this queue song</string>
<string name="desc_remove_song">Remove this song</string>
<string name="desc_song_handle">Move this song</string>
<string name="desc_queue_bar">Open the queue</string>
<string name="desc_tab_handle">Move this tab</string>
<string name="desc_clear_search">Clear search query</string>
@ -335,6 +336,7 @@
<string name="def_track">No track</string>
<string name="def_song_count">No songs</string>
<string name="def_playback">No music playing</string>
<string name="def_playlists">There\'s nothing here yet</string>
<!-- Codec Namespace | Format names -->
<eat-comment />

View file

@ -58,19 +58,23 @@ open class FakeMusicRepository : MusicRepository {
throw NotImplementedError()
}
override fun createPlaylist(name: String, songs: List<Song>) {
override suspend fun createPlaylist(name: String, songs: List<Song>) {
throw NotImplementedError()
}
override fun deletePlaylist(playlist: Playlist) {
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
throw NotImplementedError()
}
override fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
override suspend fun deletePlaylist(playlist: Playlist) {
throw NotImplementedError()
}
override fun renamePlaylist(playlist: Playlist, name: String) {
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
throw NotImplementedError()
}
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
throw NotImplementedError()
}

Some files were not shown because too many files have changed in this diff Show more