Merge branch 'dev' into feature/share
This commit is contained in:
commit
7e739061d8
102 changed files with 1769 additions and 991 deletions
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
13
app/proguard-rules.pro
vendored
13
app/proguard-rules.pro
vendored
|
@ -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
|
|
@ -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.
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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() }
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
114
app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt
Normal file
114
app/src/main/java/org/oxycblt/auxio/ui/MultiToolbar.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
11
app/src/main/res/drawable/ic_edit_24.xml
Normal file
11
app/src/main/res/drawable/ic_edit_24.xml
Normal 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>
|
12
app/src/main/res/drawable/ic_save_24.xml
Normal file
12
app/src/main/res/drawable/ic_save_24.xml
Normal 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>
|
||||
|
5
app/src/main/res/drawable/ui_item_bg.xml
Normal file
5
app/src/main/res/drawable/ui_item_bg.xml
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
41
app/src/main/res/layout/item_edit_header.xml
Normal file
41
app/src/main/res/layout/item_edit_header.xml
Normal 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>
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
9
app/src/main/res/menu/menu_edit_actions.xml
Normal file
9
app/src/main/res/menu/menu_edit_actions.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
18
app/src/main/res/menu/menu_playlist_song_actions.xml
Normal file
18
app/src/main/res/menu/menu_playlist_song_actions.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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 (&)</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>
|
|
@ -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>
|
||||
|
|
|
@ -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 (&)</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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 />
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue