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
|
## dev
|
||||||
|
|
||||||
|
#### What's New
|
||||||
|
- **Playlists.** The long-awaited feature has arrived, with more functionality coming soon.
|
||||||
|
|
||||||
#### What's Improved
|
#### What's Improved
|
||||||
- Sorting now handles numbers of arbitrary length
|
- Sorting now handles numbers of arbitrary length
|
||||||
- Punctuation is now ignored in sorting with intelligent sort names disabled
|
- 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-jdk8:$kotlin_version"
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$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-core:$coroutines_version"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$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"
|
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
|
||||||
testImplementation "junit:junit:4.13.2"
|
testImplementation "junit:junit:4.13.2"
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||||
|
|
11
app/proguard-rules.pro
vendored
11
app/proguard-rules.pro
vendored
|
@ -23,3 +23,14 @@
|
||||||
# Obsfucation is what proprietary software does to keep the user unaware of it's abuses.
|
# 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.
|
# 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 androidx.core.graphics.drawable.IconCompat
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import org.oxycblt.auxio.home.HomeSettings
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
|
@ -39,6 +40,7 @@ class Auxio : Application() {
|
||||||
@Inject lateinit var imageSettings: ImageSettings
|
@Inject lateinit var imageSettings: ImageSettings
|
||||||
@Inject lateinit var playbackSettings: PlaybackSettings
|
@Inject lateinit var playbackSettings: PlaybackSettings
|
||||||
@Inject lateinit var uiSettings: UISettings
|
@Inject lateinit var uiSettings: UISettings
|
||||||
|
@Inject lateinit var homeSettings: HomeSettings
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
@ -46,6 +48,7 @@ class Auxio : Application() {
|
||||||
imageSettings.migrate()
|
imageSettings.migrate()
|
||||||
playbackSettings.migrate()
|
playbackSettings.migrate()
|
||||||
uiSettings.migrate()
|
uiSettings.migrate()
|
||||||
|
homeSettings.migrate()
|
||||||
// Adding static shortcuts in a dynamic manner is better than declaring them
|
// Adding static shortcuts in a dynamic manner is better than declaring them
|
||||||
// manually, as it will properly handle the difference between debug and release
|
// manually, as it will properly handle the difference between debug and release
|
||||||
// Auxio instances.
|
// Auxio instances.
|
||||||
|
|
|
@ -49,6 +49,12 @@ object IntegerTable {
|
||||||
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
|
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
|
||||||
/** DiscHeaderViewHolder */
|
/** DiscHeaderViewHolder */
|
||||||
const val VIEW_TYPE_DISC_HEADER = 0xA00B
|
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 */
|
/** "Music playback" notification code */
|
||||||
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
|
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
|
||||||
/** "Music loading" notification code */
|
/** "Music loading" notification code */
|
||||||
|
|
|
@ -51,6 +51,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
* TODO: Fix UID naming
|
* TODO: Fix UID naming
|
||||||
* TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims)
|
* TODO: Leverage FlexibleListAdapter more in dialogs (Disable item anims)
|
||||||
* TODO: Add more logging
|
* TODO: Add more logging
|
||||||
|
* TODO: Try to move on from synchronized and volatile in shared objs
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
|
@ -38,6 +38,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||||
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
|
@ -66,6 +67,7 @@ class MainFragment :
|
||||||
private val musicModel: MusicViewModel by activityViewModels()
|
private val musicModel: MusicViewModel by activityViewModels()
|
||||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
private val selectionModel: SelectionViewModel by activityViewModels()
|
private val selectionModel: SelectionViewModel by activityViewModels()
|
||||||
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
private val callback = DynamicBackPressedCallback()
|
private val callback = DynamicBackPressedCallback()
|
||||||
private var lastInsets: WindowInsets? = null
|
private var lastInsets: WindowInsets? = null
|
||||||
private var elevationNormal = 0f
|
private var elevationNormal = 0f
|
||||||
|
@ -458,6 +460,11 @@ class MainFragment :
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear out pending playlist edits.
|
||||||
|
if (detailModel.dropPlaylistEdit()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Clear out any prior selections.
|
// Clear out any prior selections.
|
||||||
if (selectionModel.drop()) {
|
if (selectionModel.drop()) {
|
||||||
return
|
return
|
||||||
|
@ -487,6 +494,7 @@ class MainFragment :
|
||||||
isEnabled =
|
isEnabled =
|
||||||
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
||||||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
||||||
|
detailModel.editedPlaylist.value != null ||
|
||||||
selectionModel.selected.value.isNotEmpty() ||
|
selectionModel.selected.value.isNotEmpty() ||
|
||||||
exploreNavController.currentDestination?.id !=
|
exploreNavController.currentDestination?.id !=
|
||||||
exploreNavController.graph.startDestinationId
|
exploreNavController.graph.startDestinationId
|
||||||
|
|
|
@ -93,7 +93,7 @@ class AlbumDetailFragment :
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
// --- UI SETUP --
|
// --- UI SETUP --
|
||||||
binding.detailToolbar.apply {
|
binding.detailNormalToolbar.apply {
|
||||||
inflateMenu(R.menu.menu_album_detail)
|
inflateMenu(R.menu.menu_album_detail)
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||||
setOnMenuItemClickListener(this@AlbumDetailFragment)
|
setOnMenuItemClickListener(this@AlbumDetailFragment)
|
||||||
|
@ -124,7 +124,7 @@ class AlbumDetailFragment :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.detailToolbar.setOnMenuItemClickListener(null)
|
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
binding.detailRecycler.adapter = null
|
binding.detailRecycler.adapter = null
|
||||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
// 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.
|
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||||
|
@ -218,7 +218,7 @@ class AlbumDetailFragment :
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requireBinding().detailToolbar.title = album.name.resolve(requireContext())
|
requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
|
||||||
albumHeaderAdapter.setParent(album)
|
albumHeaderAdapter.setParent(album)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -317,6 +317,13 @@ class AlbumDetailFragment :
|
||||||
|
|
||||||
private fun updateSelection(selected: List<Music>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
albumListAdapter.setSelected(selected.toSet())
|
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)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
binding.detailToolbar.apply {
|
binding.detailNormalToolbar.apply {
|
||||||
inflateMenu(R.menu.menu_parent_detail)
|
inflateMenu(R.menu.menu_parent_detail)
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||||
setOnMenuItemClickListener(this@ArtistDetailFragment)
|
setOnMenuItemClickListener(this@ArtistDetailFragment)
|
||||||
|
@ -101,7 +101,10 @@ class ArtistDetailFragment :
|
||||||
adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
|
adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
|
||||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||||
if (it != 0) {
|
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
|
item is Divider || item is Header
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
|
@ -122,7 +125,7 @@ class ArtistDetailFragment :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.detailToolbar.setOnMenuItemClickListener(null)
|
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
binding.detailRecycler.adapter = null
|
binding.detailRecycler.adapter = null
|
||||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
// 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.
|
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||||
|
@ -227,7 +230,7 @@ class ArtistDetailFragment :
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requireBinding().detailToolbar.title = artist.name.resolve(requireContext())
|
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
|
||||||
artistHeaderAdapter.setParent(artist)
|
artistHeaderAdapter.setParent(artist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -287,6 +290,13 @@ class ArtistDetailFragment :
|
||||||
|
|
||||||
private fun updateSelection(selected: List<Music>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
artistListAdapter.setSelected(selected.toSet())
|
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
|
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
|
||||||
// used within the detail layouts.
|
// 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
|
// 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.
|
// 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.launch
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.detail.list.EditHeader
|
||||||
import org.oxycblt.auxio.detail.list.SortHeader
|
import org.oxycblt.auxio.detail.list.SortHeader
|
||||||
import org.oxycblt.auxio.list.BasicHeader
|
import org.oxycblt.auxio.list.BasicHeader
|
||||||
import org.oxycblt.auxio.list.Divider
|
import org.oxycblt.auxio.list.Divider
|
||||||
|
@ -145,6 +146,7 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PLAYLIST ---
|
// --- PLAYLIST ---
|
||||||
|
|
||||||
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
|
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
|
||||||
/** The current [Playlist] to display. Null if there is nothing to do. */
|
/** The current [Playlist] to display. Null if there is nothing to do. */
|
||||||
val currentPlaylist: StateFlow<Playlist?>
|
val currentPlaylist: StateFlow<Playlist?>
|
||||||
|
@ -158,14 +160,13 @@ constructor(
|
||||||
val playlistInstructions: Event<UpdateInstructions>
|
val playlistInstructions: Event<UpdateInstructions>
|
||||||
get() = _playlistInstructions
|
get() = _playlistInstructions
|
||||||
|
|
||||||
/** The current [Sort] used for [Song]s in [playlistList]. */
|
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
|
||||||
var playlistSongSort: Sort
|
/**
|
||||||
get() = musicSettings.playlistSongSort
|
* The new playlist songs created during the current editing session. Null if no editing session
|
||||||
set(value) {
|
* is occurring.
|
||||||
musicSettings.playlistSongSort = value
|
*/
|
||||||
// Refresh the playlist list to reflect the new sort.
|
val editedPlaylist: StateFlow<List<Song>?>
|
||||||
currentPlaylist.value?.let { refreshPlaylistList(it, true) }
|
get() = _editedPlaylist
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently
|
* 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) {
|
if (changes.userLibrary && userLibrary != null) {
|
||||||
val playlist = currentPlaylist.value
|
val playlist = currentPlaylist.value
|
||||||
if (playlist != null) {
|
if (playlist != null) {
|
||||||
|
logD("Updated playlist to ${currentPlaylist.value}")
|
||||||
_currentPlaylist.value =
|
_currentPlaylist.value =
|
||||||
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
|
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
|
||||||
}
|
}
|
||||||
|
@ -283,6 +285,91 @@ constructor(
|
||||||
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
|
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) {
|
private fun refreshAudioInfo(song: Song) {
|
||||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||||
currentSongJob?.cancel()
|
currentSongJob?.cancel()
|
||||||
|
@ -406,20 +493,21 @@ constructor(
|
||||||
_genreList.value = list
|
_genreList.value = list
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshPlaylistList(playlist: Playlist, replace: Boolean = false) {
|
private fun refreshPlaylistList(
|
||||||
|
playlist: Playlist,
|
||||||
|
instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||||
|
) {
|
||||||
logD("Refreshing playlist list")
|
logD("Refreshing playlist list")
|
||||||
var instructions: UpdateInstructions = UpdateInstructions.Diff
|
|
||||||
val list = mutableListOf<Item>()
|
val list = mutableListOf<Item>()
|
||||||
|
|
||||||
if (playlist.songs.isNotEmpty()) {
|
val songs = editedPlaylist.value ?: playlist.songs
|
||||||
val header = SortHeader(R.string.lbl_songs)
|
if (songs.isNotEmpty()) {
|
||||||
|
val header = EditHeader(R.string.lbl_songs)
|
||||||
list.add(Divider(header))
|
list.add(Divider(header))
|
||||||
list.add(header)
|
list.add(header)
|
||||||
if (replace) {
|
list.addAll(songs)
|
||||||
instructions = UpdateInstructions.Replace(list.size)
|
|
||||||
}
|
|
||||||
list.addAll(playlistSongSort.songs(playlist.songs))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_playlistInstructions.put(instructions)
|
_playlistInstructions.put(instructions)
|
||||||
_playlistList.value = list
|
_playlistList.value = list
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,7 +84,7 @@ class GenreDetailFragment :
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
binding.detailToolbar.apply {
|
binding.detailNormalToolbar.apply {
|
||||||
inflateMenu(R.menu.menu_parent_detail)
|
inflateMenu(R.menu.menu_parent_detail)
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||||
setOnMenuItemClickListener(this@GenreDetailFragment)
|
setOnMenuItemClickListener(this@GenreDetailFragment)
|
||||||
|
@ -94,7 +94,10 @@ class GenreDetailFragment :
|
||||||
adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
|
adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
|
||||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||||
if (it != 0) {
|
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
|
item is Divider || item is Header
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
|
@ -115,7 +118,7 @@ class GenreDetailFragment :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.detailToolbar.setOnMenuItemClickListener(null)
|
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
binding.detailRecycler.adapter = null
|
binding.detailRecycler.adapter = null
|
||||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
// 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.
|
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||||
|
@ -218,7 +221,7 @@ class GenreDetailFragment :
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requireBinding().detailToolbar.title = genre.name.resolve(requireContext())
|
requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
|
||||||
genreHeaderAdapter.setParent(genre)
|
genreHeaderAdapter.setParent(genre)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -264,6 +267,13 @@ class GenreDetailFragment :
|
||||||
|
|
||||||
private fun updateSelection(selected: List<Music>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
genreListAdapter.setSelected(selected.toSet())
|
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.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavDestination
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.ConcatAdapter
|
import androidx.recyclerview.widget.ConcatAdapter
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||||
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
||||||
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter
|
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.PlaylistDetailListAdapter
|
||||||
|
import org.oxycblt.auxio.detail.list.PlaylistDragCallback
|
||||||
import org.oxycblt.auxio.list.Divider
|
import org.oxycblt.auxio.list.Divider
|
||||||
import org.oxycblt.auxio.list.Header
|
import org.oxycblt.auxio.list.Header
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.Sort
|
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||||
|
@ -55,7 +58,8 @@ import org.oxycblt.auxio.util.*
|
||||||
class PlaylistDetailFragment :
|
class PlaylistDetailFragment :
|
||||||
ListFragment<Song, FragmentDetailBinding>(),
|
ListFragment<Song, FragmentDetailBinding>(),
|
||||||
DetailHeaderAdapter.Listener,
|
DetailHeaderAdapter.Listener,
|
||||||
DetailListAdapter.Listener<Song> {
|
PlaylistDetailListAdapter.Listener,
|
||||||
|
NavController.OnDestinationChangedListener {
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
override val navModel: NavigationViewModel by activityViewModels()
|
override val navModel: NavigationViewModel by activityViewModels()
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
@ -66,6 +70,8 @@ class PlaylistDetailFragment :
|
||||||
private val args: PlaylistDetailFragmentArgs by navArgs()
|
private val args: PlaylistDetailFragmentArgs by navArgs()
|
||||||
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
|
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
|
||||||
private val playlistListAdapter = PlaylistDetailListAdapter(this)
|
private val playlistListAdapter = PlaylistDetailListAdapter(this)
|
||||||
|
private var touchHelper: ItemTouchHelper? = null
|
||||||
|
private var initialNavDestinationChange = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -84,17 +90,29 @@ class PlaylistDetailFragment :
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
binding.detailToolbar.apply {
|
binding.detailNormalToolbar.apply {
|
||||||
inflateMenu(R.menu.menu_playlist_detail)
|
inflateMenu(R.menu.menu_playlist_detail)
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||||
setOnMenuItemClickListener(this@PlaylistDetailFragment)
|
setOnMenuItemClickListener(this@PlaylistDetailFragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.detailEditToolbar.apply {
|
||||||
|
setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
|
||||||
|
setOnMenuItemClickListener(this@PlaylistDetailFragment)
|
||||||
|
}
|
||||||
|
|
||||||
binding.detailRecycler.apply {
|
binding.detailRecycler.apply {
|
||||||
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
|
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
|
||||||
|
touchHelper =
|
||||||
|
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
|
||||||
|
it.attachToRecyclerView(this)
|
||||||
|
}
|
||||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||||
if (it != 0) {
|
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
|
item is Divider || item is Header
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
|
@ -107,21 +125,53 @@ class PlaylistDetailFragment :
|
||||||
detailModel.setPlaylistUid(args.playlistUid)
|
detailModel.setPlaylistUid(args.playlistUid)
|
||||||
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
|
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
|
||||||
collectImmediately(detailModel.playlistList, ::updateList)
|
collectImmediately(detailModel.playlistList, ::updateList)
|
||||||
|
collectImmediately(detailModel.editedPlaylist, ::updateEditedPlaylist)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
|
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
|
||||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
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) {
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.detailToolbar.setOnMenuItemClickListener(null)
|
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
|
touchHelper = null
|
||||||
binding.detailRecycler.adapter = null
|
binding.detailRecycler.adapter = null
|
||||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
// 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.
|
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||||
detailModel.playlistInstructions.consume()
|
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 {
|
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||||
if (super.onMenuItemClick(item)) {
|
if (super.onMenuItemClick(item)) {
|
||||||
return true
|
return true
|
||||||
|
@ -151,6 +201,10 @@ class PlaylistDetailFragment :
|
||||||
requireContext().share(currentPlaylist)
|
requireContext().share(currentPlaylist)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_save -> {
|
||||||
|
detailModel.savePlaylistEdit()
|
||||||
|
true
|
||||||
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -159,8 +213,12 @@ class PlaylistDetailFragment :
|
||||||
playbackModel.playFromPlaylist(item, unlikelyToBeNull(detailModel.currentPlaylist.value))
|
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) {
|
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() {
|
override fun onPlay() {
|
||||||
|
@ -171,48 +229,21 @@ class PlaylistDetailFragment :
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenSortMenu(anchor: View) {
|
override fun onStartEdit() {
|
||||||
openMenu(anchor, R.menu.menu_playlist_sort) {
|
detailModel.startPlaylistEdit()
|
||||||
// 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 onOpenSortMenu(anchor: View) {}
|
||||||
|
|
||||||
private fun updatePlaylist(playlist: Playlist?) {
|
private fun updatePlaylist(playlist: Playlist?) {
|
||||||
if (playlist == null) {
|
if (playlist == null) {
|
||||||
// Playlist we were showing no longer exists.
|
// Playlist we were showing no longer exists.
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
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)
|
playlistHeaderAdapter.setParent(playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -254,8 +285,38 @@ class PlaylistDetailFragment :
|
||||||
playlistListAdapter.update(list, detailModel.playlistInstructions.consume())
|
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>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
playlistListAdapter.setSelected(selected.toSet())
|
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) {
|
fun setParent(parent: T) {
|
||||||
currentParent = parent
|
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)
|
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
|
@ -38,11 +39,27 @@ import org.oxycblt.auxio.util.inflater
|
||||||
*/
|
*/
|
||||||
class PlaylistDetailHeaderAdapter(private val listener: Listener) :
|
class PlaylistDetailHeaderAdapter(private val listener: Listener) :
|
||||||
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
|
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
|
||||||
|
private var editedPlaylist: List<Song>? = null
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
PlaylistDetailHeaderViewHolder.from(parent)
|
PlaylistDetailHeaderViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) =
|
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.
|
* Bind new data to this instance.
|
||||||
*
|
*
|
||||||
* @param playlist The new [Playlist] to bind.
|
* @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.
|
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
fun bind(playlist: Playlist, listener: DetailHeaderAdapter.Listener) {
|
fun bind(
|
||||||
binding.detailCover.bind(playlist)
|
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.detailType.text = binding.context.getString(R.string.lbl_playlist)
|
||||||
binding.detailName.text = playlist.name.resolve(binding.context)
|
binding.detailName.text = playlist.name.resolve(binding.context)
|
||||||
// Nothing about a playlist is applicable to the sub-head text.
|
// Nothing about a playlist is applicable to the sub-head text.
|
||||||
binding.detailSubhead.isVisible = false
|
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.
|
// The song count of the playlist maps to the info text.
|
||||||
binding.detailInfo.apply {
|
binding.detailInfo.text =
|
||||||
isVisible = true
|
if (songs.isNotEmpty()) {
|
||||||
text =
|
binding.context.getString(
|
||||||
if (playlist.songs.isNotEmpty()) {
|
R.string.fmt_two,
|
||||||
binding.context.getString(
|
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
|
||||||
R.string.fmt_two,
|
durationMs.formatDurationMs(true))
|
||||||
binding.context.getPlural(R.plurals.fmt_song_count, playlist.songs.size),
|
} else {
|
||||||
playlist.durationMs.formatDurationMs(true))
|
binding.context.getString(R.string.def_song_count)
|
||||||
} else {
|
}
|
||||||
binding.context.getString(R.string.def_song_count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.detailPlayButton.apply {
|
binding.detailPlayButton.apply {
|
||||||
isEnabled = playlist.songs.isNotEmpty()
|
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
|
||||||
setOnClickListener { listener.onPlay() }
|
setOnClickListener { listener.onPlay() }
|
||||||
}
|
}
|
||||||
binding.detailShuffleButton.apply {
|
binding.detailShuffleButton.apply {
|
||||||
isEnabled = playlist.songs.isNotEmpty()
|
isEnabled = playlist.songs.isNotEmpty() && editedPlaylist == null
|
||||||
setOnClickListener { listener.onShuffle() }
|
setOnClickListener { listener.onShuffle() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -111,8 +111,8 @@ abstract class DetailListAdapter(
|
||||||
data class SortHeader(@StringRes override val titleRes: Int) : Header
|
data class SortHeader(@StringRes override val titleRes: Int) : Header
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [BasicHeader] that adds
|
* A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create
|
||||||
* a button opening a menu for sorting. Use [from] to create an instance.
|
* an instance.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
|
@ -126,7 +126,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
|
||||||
*/
|
*/
|
||||||
fun bind(sortHeader: SortHeader, listener: DetailListAdapter.Listener<*>) {
|
fun bind(sortHeader: SortHeader, listener: DetailListAdapter.Listener<*>) {
|
||||||
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
|
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
|
// Add a Tooltip based on the content description so that the purpose of this
|
||||||
// button can be clear.
|
// button can be clear.
|
||||||
TooltipCompat.setTooltipText(this, contentDescription)
|
TooltipCompat.setTooltipText(this, contentDescription)
|
||||||
|
|
|
@ -18,53 +18,265 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.detail.list
|
package org.oxycblt.auxio.detail.list
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.graphics.drawable.LayerDrawable
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
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 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.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.adapter.SimpleDiffCallback
|
||||||
|
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
|
||||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.music.Song
|
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.
|
* @param listener A [DetailListAdapter.Listener] to bind interactions to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class PlaylistDetailListAdapter(private val listener: Listener<Song>) :
|
class PlaylistDetailListAdapter(private val listener: Listener) :
|
||||||
DetailListAdapter(listener, DIFF_CALLBACK) {
|
DetailListAdapter(listener, DIFF_CALLBACK) {
|
||||||
|
private var isEditing = false
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) =
|
override fun getItemViewType(position: Int) =
|
||||||
when (getItem(position)) {
|
when (getItem(position)) {
|
||||||
// Support generic song items.
|
is EditHeader -> EditHeaderViewHolder.VIEW_TYPE
|
||||||
is Song -> SongViewHolder.VIEW_TYPE
|
is Song -> PlaylistSongViewHolder.VIEW_TYPE
|
||||||
else -> super.getItemViewType(position)
|
else -> super.getItemViewType(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
if (viewType == SongViewHolder.VIEW_TYPE) {
|
when (viewType) {
|
||||||
SongViewHolder.from(parent)
|
EditHeaderViewHolder.VIEW_TYPE -> EditHeaderViewHolder.from(parent)
|
||||||
} else {
|
PlaylistSongViewHolder.VIEW_TYPE -> PlaylistSongViewHolder.from(parent)
|
||||||
super.onCreateViewHolder(parent, viewType)
|
else -> super.onCreateViewHolder(parent, viewType)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(
|
||||||
super.onBindViewHolder(holder, position)
|
holder: RecyclerView.ViewHolder,
|
||||||
val item = getItem(position)
|
position: Int,
|
||||||
if (item is Song) {
|
payloads: List<Any>
|
||||||
(holder as SongViewHolder).bind(item, listener)
|
) {
|
||||||
|
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 =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleDiffCallback<Item>() {
|
object : SimpleDiffCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
|
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
|
||||||
when {
|
when {
|
||||||
oldItem is Song && newItem is Song ->
|
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)
|
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 ---
|
// --- UI SETUP ---
|
||||||
binding.homeAppbar.addOnOffsetChangedListener(this)
|
binding.homeAppbar.addOnOffsetChangedListener(this)
|
||||||
binding.homeToolbar.apply {
|
binding.homeNormalToolbar.apply {
|
||||||
setOnMenuItemClickListener(this@HomeFragment)
|
setOnMenuItemClickListener(this@HomeFragment)
|
||||||
MenuCompat.setGroupDividerEnabled(menu, true)
|
MenuCompat.setGroupDividerEnabled(menu, true)
|
||||||
}
|
}
|
||||||
|
@ -169,7 +169,7 @@ class HomeFragment :
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
storagePermissionLauncher = null
|
storagePermissionLauncher = null
|
||||||
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
||||||
binding.homeToolbar.setOnMenuItemClickListener(null)
|
binding.homeNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
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,
|
// 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
|
// the alpha transition is shifted such that the Toolbar becomes fully transparent
|
||||||
// when the AppBarLayout is only at half-collapsed.
|
// when the AppBarLayout is only at half-collapsed.
|
||||||
binding.homeSelectionToolbar.alpha =
|
binding.homeToolbar.alpha = 1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
|
||||||
1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
|
|
||||||
binding.homeContent.updatePadding(
|
binding.homeContent.updatePadding(
|
||||||
bottom = binding.homeAppbar.totalScrollRange + verticalOffset)
|
bottom = binding.homeAppbar.totalScrollRange + verticalOffset)
|
||||||
}
|
}
|
||||||
|
@ -243,7 +242,7 @@ class HomeFragment :
|
||||||
binding.homePager.adapter =
|
binding.homePager.adapter =
|
||||||
HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner)
|
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) {
|
if (homeModel.currentTabModes.size == 1) {
|
||||||
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
||||||
// behavior.
|
// behavior.
|
||||||
|
@ -285,7 +284,7 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
val sortMenu =
|
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)
|
val toHighlight = homeModel.getSortForTab(tabMode)
|
||||||
|
|
||||||
for (option in sortMenu) {
|
for (option in sortMenu) {
|
||||||
|
@ -456,11 +455,15 @@ class HomeFragment :
|
||||||
|
|
||||||
private fun updateSelection(selected: List<Music>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
|
if (selected.isNotEmpty()) {
|
||||||
selected.isNotEmpty()) {
|
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
|
||||||
// New selection started, show the AppBarLayout to indicate the new state.
|
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
|
||||||
logD("Significant selection occurred, expanding AppBar")
|
// New selection started, show the AppBarLayout to indicate the new state.
|
||||||
binding.homeAppbar.expandWithScrollingRecycler()
|
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))
|
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
|
||||||
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
|
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
|
||||||
|
|
||||||
// Add the new playlist tab to old tab configurations
|
// The playlist tab is now parsed, but it needs to be made visible.
|
||||||
val correctedTabs = oldTabs + Tab.Visible(MusicMode.PLAYLISTS)
|
val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS }
|
||||||
|
if (playlistIndex > -1) { // Sanity check
|
||||||
|
oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS)
|
||||||
|
}
|
||||||
sharedPreferences.edit {
|
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)
|
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.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
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 :
|
class PlaylistListFragment :
|
||||||
ListFragment<Playlist, FragmentHomeListBinding>(),
|
ListFragment<Playlist, FragmentHomeListBinding>(),
|
||||||
FastScrollRecyclerView.PopupProvider,
|
FastScrollRecyclerView.PopupProvider,
|
||||||
|
|
|
@ -24,7 +24,7 @@ import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemTabBinding
|
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.list.recycler.DialogRecyclerView
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.util.inflater
|
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.
|
* 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>() {
|
RecyclerView.Adapter<TabViewHolder>() {
|
||||||
/** The current array of [Tab]s. */
|
/** The current array of [Tab]s. */
|
||||||
var tabs = arrayOf<Tab>()
|
var tabs = arrayOf<Tab>()
|
||||||
|
@ -97,10 +97,10 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
||||||
* Bind new data to this instance.
|
* Bind new data to this instance.
|
||||||
*
|
*
|
||||||
* @param tab The new [Tab] to bind.
|
* @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")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
fun bind(tab: Tab, listener: EditableListListener<Tab>) {
|
fun bind(tab: Tab, listener: EditClickListListener<Tab>) {
|
||||||
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
|
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
|
||||||
binding.tabCheckBox.apply {
|
binding.tabCheckBox.apply {
|
||||||
// Update the CheckBox name to align with the mode
|
// 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.R
|
||||||
import org.oxycblt.auxio.databinding.DialogTabsBinding
|
import org.oxycblt.auxio.databinding.DialogTabsBinding
|
||||||
import org.oxycblt.auxio.home.HomeSettings
|
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.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.logD
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class TabCustomizeDialog :
|
class TabCustomizeDialog :
|
||||||
ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener<Tab> {
|
ViewBindingDialogFragment<DialogTabsBinding>(), EditClickListListener<Tab> {
|
||||||
private val tabAdapter = TabAdapter(this)
|
private val tabAdapter = TabAdapter(this)
|
||||||
private var touchHelper: ItemTouchHelper? = null
|
private var touchHelper: ItemTouchHelper? = null
|
||||||
@Inject lateinit var homeSettings: HomeSettings
|
@Inject lateinit var homeSettings: HomeSettings
|
||||||
|
|
|
@ -95,7 +95,7 @@ constructor(
|
||||||
target
|
target
|
||||||
.onConfigRequest(
|
.onConfigRequest(
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(song)
|
.data(listOf(song))
|
||||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||||
.size(Size.ORIGINAL)
|
.size(Size.ORIGINAL)
|
||||||
.transformations(SquareFrameTransform.INSTANCE))
|
.transformations(SquareFrameTransform.INSTANCE))
|
||||||
|
|
|
@ -49,6 +49,9 @@ import org.oxycblt.auxio.util.getInteger
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*
|
*
|
||||||
* TODO: Rework content descriptions here
|
* 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
|
class ImageGroup
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
|
|
|
@ -96,49 +96,54 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
*
|
*
|
||||||
* @param song The [Song] to bind.
|
* @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.
|
* Bind an [Album]'s cover to this view, also updating the content description.
|
||||||
*
|
*
|
||||||
* @param album the [Album] to bind.
|
* @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.
|
* Bind an [Artist]'s image to this view, also updating the content description.
|
||||||
*
|
*
|
||||||
* @param artist the [Artist] to bind.
|
* @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.
|
* Bind an [Genre]'s image to this view, also updating the content description.
|
||||||
*
|
*
|
||||||
* @param genre the [Genre] to bind.
|
* @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.
|
* 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) =
|
fun bind(playlist: Playlist, songs: List<Song>? = null) =
|
||||||
bindImpl(playlist, R.drawable.ic_playlist_24, R.string.desc_playlist_image)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private fun bind(parent: MusicParent, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
|
||||||
* Internally bind a [Music]'s image to this view.
|
bind(parent.songs, context.getString(descRes, parent.name.resolve(context)), errorRes)
|
||||||
*
|
}
|
||||||
* @param music The music to find.
|
|
||||||
* @param errorRes The error drawable resource to use if the music cannot be loaded.
|
private fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) {
|
||||||
* @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) {
|
|
||||||
val request =
|
val request =
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(music)
|
.data(songs)
|
||||||
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
|
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
|
||||||
.transformations(SquareFrameTransform.INSTANCE)
|
.transformations(SquareFrameTransform.INSTANCE)
|
||||||
.target(this)
|
.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.
|
// Dispose of any previous image request and load a new image.
|
||||||
CoilUtils.dispose(this)
|
CoilUtils.dispose(this)
|
||||||
imageLoader.enqueue(request)
|
imageLoader.enqueue(request)
|
||||||
// Update the content description to the specified resource.
|
contentDescription = desc
|
||||||
contentDescription = context.getString(descRes, music.name.resolve(context))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -18,162 +18,31 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.extractor
|
package org.oxycblt.auxio.image.extractor
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.decode.DataSource
|
|
||||||
import coil.decode.ImageSource
|
|
||||||
import coil.fetch.FetchResult
|
|
||||||
import coil.fetch.Fetcher
|
import coil.fetch.Fetcher
|
||||||
import coil.fetch.SourceResult
|
|
||||||
import coil.key.Keyer
|
import coil.key.Keyer
|
||||||
import coil.request.Options
|
import coil.request.Options
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import javax.inject.Inject
|
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.*
|
import org.oxycblt.auxio.music.*
|
||||||
|
|
||||||
class SongKeyer @Inject constructor() : Keyer<Song> {
|
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||||
override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}"
|
Keyer<List<Song>> {
|
||||||
|
override fun key(data: List<Song>, options: Options) =
|
||||||
|
"${coverExtractor.computeAlbumOrdering(data).hashCode()}"
|
||||||
}
|
}
|
||||||
|
|
||||||
class ParentKeyer @Inject constructor() : Keyer<MusicParent> {
|
class SongCoverFetcher
|
||||||
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
|
|
||||||
private constructor(
|
private constructor(
|
||||||
private val context: Context,
|
private val songs: List<Song>,
|
||||||
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 size: Size,
|
private val size: Size,
|
||||||
private val artist: Artist
|
private val coverExtractor: CoverExtractor,
|
||||||
) : Fetcher {
|
) : Fetcher {
|
||||||
override suspend fun fetch(): FetchResult? {
|
override suspend fun fetch() = coverExtractor.extract(songs, size)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Factory @Inject constructor(private val extractor: CoverExtractor) :
|
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||||
Fetcher.Factory<Artist> {
|
Fetcher.Factory<List<Song>> {
|
||||||
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
|
override fun create(data: List<Song>, options: Options, imageLoader: ImageLoader) =
|
||||||
ArtistImageFetcher(options.context, extractor, options.size, data)
|
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
|
package org.oxycblt.auxio.image.extractor
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
import android.media.MediaMetadataRetriever
|
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.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.exoplayer.MetadataRetriever
|
import androidx.media3.exoplayer.MetadataRetriever
|
||||||
import androidx.media3.exoplayer.source.MediaSource
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
import androidx.media3.extractor.metadata.flac.PictureFrame
|
import androidx.media3.extractor.metadata.flac.PictureFrame
|
||||||
import androidx.media3.extractor.metadata.id3.ApicFrame
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
@ -33,9 +46,13 @@ import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.guava.asDeferred
|
import kotlinx.coroutines.guava.asDeferred
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
import org.oxycblt.auxio.image.CoverMode
|
import org.oxycblt.auxio.image.CoverMode
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
|
import org.oxycblt.auxio.list.Sort
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
|
@ -46,7 +63,28 @@ constructor(
|
||||||
private val imageSettings: ImageSettings,
|
private val imageSettings: ImageSettings,
|
||||||
private val mediaSourceFactory: MediaSource.Factory
|
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 {
|
try {
|
||||||
when (imageSettings.coverMode) {
|
when (imageSettings.coverMode) {
|
||||||
CoverMode.OFF -> null
|
CoverMode.OFF -> null
|
||||||
|
@ -125,4 +163,58 @@ constructor(
|
||||||
private suspend fun extractMediaStoreCover(album: Album) =
|
private suspend fun extractMediaStoreCover(album: Album) =
|
||||||
// Eliminate any chance that this blocking call might mess up the loading process
|
// Eliminate any chance that this blocking call might mess up the loading process
|
||||||
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
|
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(
|
fun imageLoader(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
songKeyer: SongKeyer,
|
songKeyer: SongKeyer,
|
||||||
parentKeyer: ParentKeyer,
|
songFactory: SongCoverFetcher.Factory
|
||||||
songFactory: AlbumCoverFetcher.SongFactory,
|
|
||||||
albumFactory: AlbumCoverFetcher.AlbumFactory,
|
|
||||||
artistFactory: ArtistImageFetcher.Factory,
|
|
||||||
genreFactory: GenreImageFetcher.Factory,
|
|
||||||
playlistFactory: PlaylistImageFetcher.Factory
|
|
||||||
) =
|
) =
|
||||||
ImageLoader.Builder(context)
|
ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
// Add fetchers for Music components to make them usable with ImageRequest
|
// Add fetchers for Music components to make them usable with ImageRequest
|
||||||
add(songKeyer)
|
add(songKeyer)
|
||||||
add(parentKeyer)
|
|
||||||
add(songFactory)
|
add(songFactory)
|
||||||
add(albumFactory)
|
|
||||||
add(artistFactory)
|
|
||||||
add(genreFactory)
|
|
||||||
add(playlistFactory)
|
|
||||||
}
|
}
|
||||||
// Use our own crossfade with error drawable support
|
// Use our own crossfade with error drawable support
|
||||||
.transitionFactory(ErrorCrossfadeTransitionFactory())
|
.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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface EditableListListener<in T> : ClickableListListener<T> {
|
interface EditableListListener {
|
||||||
/**
|
/**
|
||||||
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
|
* 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)
|
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.
|
* Binds this instance to a list item.
|
||||||
*
|
*
|
||||||
|
@ -78,13 +101,7 @@ interface EditableListListener<in T> : ClickableListListener<T> {
|
||||||
dragHandle: View
|
dragHandle: View
|
||||||
) {
|
) {
|
||||||
bind(item, viewHolder, bodyView)
|
bind(item, viewHolder, bodyView)
|
||||||
dragHandle.setOnTouchListener { _, motionEvent ->
|
bind(viewHolder, dragHandle)
|
||||||
dragHandle.performClick()
|
|
||||||
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
|
|
||||||
onPickUp(viewHolder)
|
|
||||||
true
|
|
||||||
} else false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -206,19 +206,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
||||||
*/
|
*/
|
||||||
fun getPlaylistComparator(direction: Direction): Comparator<Playlist>? = null
|
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.
|
* Sort by the item's name.
|
||||||
*
|
*
|
||||||
|
@ -455,7 +442,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
||||||
*/
|
*/
|
||||||
fun fromIntCode(intCode: Int) =
|
fun fromIntCode(intCode: Int) =
|
||||||
when (intCode) {
|
when (intCode) {
|
||||||
ByNone.intCode -> ByNone
|
|
||||||
ByName.intCode -> ByName
|
ByName.intCode -> ByName
|
||||||
ByArtist.intCode -> ByArtist
|
ByArtist.intCode -> ByArtist
|
||||||
ByAlbum.intCode -> ByAlbum
|
ByAlbum.intCode -> ByAlbum
|
||||||
|
@ -477,7 +463,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
||||||
*/
|
*/
|
||||||
fun fromItemId(@IdRes itemId: Int) =
|
fun fromItemId(@IdRes itemId: Int) =
|
||||||
when (itemId) {
|
when (itemId) {
|
||||||
ByNone.itemId -> ByNone
|
|
||||||
ByName.itemId -> ByName
|
ByName.itemId -> ByName
|
||||||
ByAlbum.itemId -> ByAlbum
|
ByAlbum.itemId -> ByAlbum
|
||||||
ByArtist.itemId -> ByArtist
|
ByArtist.itemId -> ByArtist
|
||||||
|
|
|
@ -93,8 +93,9 @@ sealed interface UpdateInstructions {
|
||||||
* Remove an item.
|
* Remove an item.
|
||||||
*
|
*
|
||||||
* @param at The location that the item should be removed from.
|
* @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 -> {
|
is UpdateInstructions.Remove -> {
|
||||||
currentList = newList
|
currentList = newList
|
||||||
updateCallback.onRemoved(instructions.at, 1)
|
updateCallback.onRemoved(instructions.at, instructions.size)
|
||||||
callback?.invoke()
|
callback?.invoke()
|
||||||
}
|
}
|
||||||
is UpdateInstructions.Diff,
|
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 musicModel: MusicViewModel
|
||||||
protected abstract val playbackModel: PlaybackViewModel
|
protected abstract val playbackModel: PlaybackViewModel
|
||||||
|
|
||||||
/**
|
open fun getSelectionToolbar(binding: VB): Toolbar? = null
|
||||||
* 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
|
|
||||||
|
|
||||||
override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
getSelectionToolbar(binding)?.apply {
|
getSelectionToolbar(binding)?.apply {
|
||||||
// Add cancel and menu item listeners to manage what occurs with the selection.
|
// Add cancel and menu item listeners to manage what occurs with the selection.
|
||||||
setOnSelectionCancelListener { selectionModel.drop() }
|
setNavigationOnClickListener { selectionModel.drop() }
|
||||||
setOnMenuItemClickListener(this@SelectionFragment)
|
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 Album -> musicSettings.albumSongSort.songs(it.songs)
|
||||||
is Artist -> musicSettings.artistSongSort.songs(it.songs)
|
is Artist -> musicSettings.artistSongSort.songs(it.songs)
|
||||||
is Genre -> musicSettings.genreSongSort.songs(it.songs)
|
is Genre -> musicSettings.genreSongSort.songs(it.songs)
|
||||||
is Playlist -> musicSettings.playlistSongSort.songs(it.songs)
|
is Playlist -> it.songs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.also { drop() }
|
.also { drop() }
|
||||||
|
|
|
@ -116,7 +116,7 @@ interface MusicRepository {
|
||||||
* @param name The name of the new [Playlist].
|
* @param name The name of the new [Playlist].
|
||||||
* @param songs The songs to populate the new [Playlist] with.
|
* @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].
|
* Rename a [Playlist].
|
||||||
|
@ -124,14 +124,14 @@ interface MusicRepository {
|
||||||
* @param playlist The [Playlist] to rename.
|
* @param playlist The [Playlist] to rename.
|
||||||
* @param name The name of the new [Playlist].
|
* @param name The name of the new [Playlist].
|
||||||
*/
|
*/
|
||||||
fun renamePlaylist(playlist: Playlist, name: String)
|
suspend fun renamePlaylist(playlist: Playlist, name: String)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a [Playlist].
|
* Delete a [Playlist].
|
||||||
*
|
*
|
||||||
* @param playlist The playlist to delete.
|
* @param playlist The playlist to delete.
|
||||||
*/
|
*/
|
||||||
fun deletePlaylist(playlist: Playlist)
|
suspend fun deletePlaylist(playlist: Playlist)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add the given [Song]s to a [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 songs The [Song]s to add to the [Playlist].
|
||||||
* @param playlist The [Playlist] to add to.
|
* @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
|
* Request that a music loading operation is started by the current [IndexingWorker]. Does
|
||||||
|
@ -211,12 +219,12 @@ constructor(
|
||||||
) : MusicRepository {
|
) : MusicRepository {
|
||||||
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
||||||
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
|
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
|
||||||
private var indexingWorker: MusicRepository.IndexingWorker? = null
|
@Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null
|
||||||
|
|
||||||
override var deviceLibrary: DeviceLibrary? = null
|
@Volatile override var deviceLibrary: DeviceLibrary? = null
|
||||||
override var userLibrary: MutableUserLibrary? = null
|
@Volatile override var userLibrary: MutableUserLibrary? = null
|
||||||
private var previousCompletedState: IndexingState.Completed? = null
|
@Volatile private var previousCompletedState: IndexingState.Completed? = null
|
||||||
private var currentIndexingState: IndexingState? = null
|
@Volatile private var currentIndexingState: IndexingState? = null
|
||||||
override val indexingState: IndexingState?
|
override val indexingState: IndexingState?
|
||||||
get() = currentIndexingState ?: previousCompletedState
|
get() = currentIndexingState ?: previousCompletedState
|
||||||
|
|
||||||
|
@ -264,46 +272,50 @@ constructor(
|
||||||
currentIndexingState = null
|
currentIndexingState = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
override fun find(uid: Music.UID) =
|
override fun find(uid: Music.UID) =
|
||||||
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
|
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
|
||||||
?: userLibrary?.findPlaylist(uid))
|
?: userLibrary?.findPlaylist(uid))
|
||||||
|
|
||||||
override fun createPlaylist(name: String, songs: List<Song>) {
|
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
||||||
val userLibrary = userLibrary ?: return
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
userLibrary.createPlaylist(name, songs)
|
userLibrary.createPlaylist(name, songs)
|
||||||
for (listener in updateListeners) {
|
notifyUserLibraryChange()
|
||||||
listener.onMusicChanges(
|
|
||||||
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun renamePlaylist(playlist: Playlist, name: String) {
|
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
||||||
val userLibrary = userLibrary ?: return
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
userLibrary.renamePlaylist(playlist, name)
|
userLibrary.renamePlaylist(playlist, name)
|
||||||
for (listener in updateListeners) {
|
notifyUserLibraryChange()
|
||||||
listener.onMusicChanges(
|
|
||||||
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deletePlaylist(playlist: Playlist) {
|
override suspend fun deletePlaylist(playlist: Playlist) {
|
||||||
val userLibrary = userLibrary ?: return
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
userLibrary.deletePlaylist(playlist)
|
userLibrary.deletePlaylist(playlist)
|
||||||
for (listener in updateListeners) {
|
notifyUserLibraryChange()
|
||||||
listener.onMusicChanges(
|
|
||||||
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
||||||
val userLibrary = userLibrary ?: return
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
userLibrary.addToPlaylist(playlist, songs)
|
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) {
|
for (listener in updateListeners) {
|
||||||
listener.onMusicChanges(
|
listener.onMusicChanges(
|
||||||
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
|
MusicRepository.Changes(deviceLibrary = false, userLibrary = true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
override fun requestIndex(withCache: Boolean) {
|
override fun requestIndex(withCache: Boolean) {
|
||||||
indexingWorker?.requestIndex(withCache)
|
indexingWorker?.requestIndex(withCache)
|
||||||
}
|
}
|
||||||
|
@ -383,9 +395,10 @@ constructor(
|
||||||
throw NoMusicException()
|
throw NoMusicException()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successfully loaded the library, now save the cache and create the library in
|
// Successfully loaded the library, now save the cache, create the library, and
|
||||||
// parallel.
|
// read playlist information in parallel.
|
||||||
logD("Discovered ${rawSongs.size} songs, starting finalization")
|
logD("Discovered ${rawSongs.size} songs, starting finalization")
|
||||||
|
// TODO: Indicate playlist state in loading process?
|
||||||
emitLoading(IndexingProgress.Indeterminate)
|
emitLoading(IndexingProgress.Indeterminate)
|
||||||
val deviceLibraryChannel = Channel<DeviceLibrary>()
|
val deviceLibraryChannel = Channel<DeviceLibrary>()
|
||||||
val deviceLibraryJob =
|
val deviceLibraryJob =
|
||||||
|
|
|
@ -63,8 +63,6 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
||||||
var artistSongSort: Sort
|
var artistSongSort: Sort
|
||||||
/** The [Sort] mode used in a [Genre]'s [Song] list. */
|
/** The [Sort] mode used in a [Genre]'s [Song] list. */
|
||||||
var genreSongSort: Sort
|
var genreSongSort: Sort
|
||||||
/** The [Sort] mode used in a [Playlist]'s [Song] list. */
|
|
||||||
var playlistSongSort: Sort
|
|
||||||
|
|
||||||
interface Listener {
|
interface Listener {
|
||||||
/** Called when a setting controlling how music is loaded has changed. */
|
/** 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) {
|
override fun onSettingChanged(key: String, listener: MusicSettings.Listener) {
|
||||||
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
|
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
|
||||||
// (just need to manipulate data)
|
// (just need to manipulate data)
|
||||||
|
|
|
@ -19,10 +19,13 @@
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.util.Event
|
import org.oxycblt.auxio.util.Event
|
||||||
import org.oxycblt.auxio.util.MutableEvent
|
import org.oxycblt.auxio.util.MutableEvent
|
||||||
|
|
||||||
|
@ -110,7 +113,7 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) {
|
fun createPlaylist(name: String? = null, songs: List<Song> = listOf()) {
|
||||||
if (name != null) {
|
if (name != null) {
|
||||||
musicRepository.createPlaylist(name, songs)
|
viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) }
|
||||||
} else {
|
} else {
|
||||||
_newPlaylistSongs.put(songs)
|
_newPlaylistSongs.put(songs)
|
||||||
}
|
}
|
||||||
|
@ -124,7 +127,7 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun renamePlaylist(playlist: Playlist, name: String? = null) {
|
fun renamePlaylist(playlist: Playlist, name: String? = null) {
|
||||||
if (name != null) {
|
if (name != null) {
|
||||||
musicRepository.renamePlaylist(playlist, name)
|
viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) }
|
||||||
} else {
|
} else {
|
||||||
_playlistToRename.put(playlist)
|
_playlistToRename.put(playlist)
|
||||||
}
|
}
|
||||||
|
@ -139,7 +142,7 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
|
fun deletePlaylist(playlist: Playlist, rude: Boolean = false) {
|
||||||
if (rude) {
|
if (rude) {
|
||||||
musicRepository.deletePlaylist(playlist)
|
viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) }
|
||||||
} else {
|
} else {
|
||||||
_playlistToDelete.put(playlist)
|
_playlistToDelete.put(playlist)
|
||||||
}
|
}
|
||||||
|
@ -193,7 +196,7 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) {
|
fun addToPlaylist(songs: List<Song>, playlist: Playlist? = null) {
|
||||||
if (playlist != null) {
|
if (playlist != null) {
|
||||||
musicRepository.addToPlaylist(songs, playlist)
|
viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) }
|
||||||
} else {
|
} else {
|
||||||
_songsToAdd.put(songs)
|
_songsToAdd.put(songs)
|
||||||
}
|
}
|
||||||
|
|
|
@ -131,7 +131,6 @@ class IndexerService :
|
||||||
override val scope = indexScope
|
override val scope = indexScope
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (!changes.deviceLibrary) return
|
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||||
// Wipe possibly-invalidated outdated covers
|
// Wipe possibly-invalidated outdated covers
|
||||||
imageLoader.memoryCache?.clear()
|
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 androidx.room.*
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw playlist information persisted to [UserMusicDatabase].
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
data class RawPlaylist(
|
data class RawPlaylist(
|
||||||
@Embedded val playlistInfo: PlaylistInfo,
|
@Embedded val playlistInfo: PlaylistInfo,
|
||||||
@Relation(
|
@Relation(
|
||||||
|
@ -30,12 +35,28 @@ data class RawPlaylist(
|
||||||
val songs: List<PlaylistSong>
|
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)
|
@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 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(
|
data class PlaylistSongCrossRef(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
val playlistUid: Music.UID,
|
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].
|
* Create a new [UserLibrary].
|
||||||
*
|
*
|
||||||
* @param deviceLibrary Asynchronously populated [DeviceLibrary] that can be obtained later.
|
* @param deviceLibraryChannel Asynchronously populated [DeviceLibrary] that can be obtained
|
||||||
* This allows database information to be read before the actual instance is constructed.
|
* later. This allows database information to be read before the actual instance is
|
||||||
|
* constructed.
|
||||||
* @return A new [MutableUserLibrary] with the required implementation.
|
* @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 name The name of the [Playlist].
|
||||||
* @param songs The songs to place in 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].
|
* Rename a [Playlist].
|
||||||
|
@ -86,37 +87,54 @@ interface MutableUserLibrary : UserLibrary {
|
||||||
* @param playlist The [Playlist] to rename.
|
* @param playlist The [Playlist] to rename.
|
||||||
* @param name The name of the new [Playlist].
|
* @param name The name of the new [Playlist].
|
||||||
*/
|
*/
|
||||||
fun renamePlaylist(playlist: Playlist, name: String)
|
suspend fun renamePlaylist(playlist: Playlist, name: String)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a [Playlist].
|
* Delete a [Playlist].
|
||||||
*
|
*
|
||||||
* @param playlist The playlist to delete.
|
* @param playlist The playlist to delete.
|
||||||
*/
|
*/
|
||||||
fun deletePlaylist(playlist: Playlist)
|
suspend fun deletePlaylist(playlist: Playlist)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add [Song]s to a [Playlist].
|
* Add [Song]s to a [Playlist].
|
||||||
*
|
*
|
||||||
* @param playlist The [Playlist] to add to. Must currently exist.
|
* @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
|
class UserLibraryFactoryImpl
|
||||||
@Inject
|
@Inject
|
||||||
constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) :
|
constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) :
|
||||||
UserLibrary.Factory {
|
UserLibrary.Factory {
|
||||||
override suspend fun read(deviceLibrary: Channel<DeviceLibrary>): MutableUserLibrary =
|
override suspend fun read(deviceLibraryChannel: Channel<DeviceLibrary>): MutableUserLibrary {
|
||||||
UserLibraryImpl(playlistDao, deviceLibrary.receive(), musicSettings)
|
// 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 class UserLibraryImpl(
|
||||||
private val playlistDao: PlaylistDao,
|
private val playlistDao: PlaylistDao,
|
||||||
private val deviceLibrary: DeviceLibrary,
|
private val playlistMap: MutableMap<Music.UID, PlaylistImpl>,
|
||||||
private val musicSettings: MusicSettings
|
private val musicSettings: MusicSettings
|
||||||
) : MutableUserLibrary {
|
) : MutableUserLibrary {
|
||||||
private val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
|
|
||||||
override val playlists: List<Playlist>
|
override val playlists: List<Playlist>
|
||||||
get() = playlistMap.values.toList()
|
get() = playlistMap.values.toList()
|
||||||
|
|
||||||
|
@ -124,28 +142,41 @@ private class UserLibraryImpl(
|
||||||
|
|
||||||
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
|
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
|
||||||
|
|
||||||
@Synchronized
|
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
||||||
override fun createPlaylist(name: String, songs: List<Song>) {
|
|
||||||
val playlistImpl = PlaylistImpl.from(name, songs, musicSettings)
|
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 suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
||||||
override fun renamePlaylist(playlist: Playlist, name: String) {
|
|
||||||
val playlistImpl =
|
val playlistImpl =
|
||||||
requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" }
|
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 suspend fun deletePlaylist(playlist: Playlist) {
|
||||||
override fun deletePlaylist(playlist: Playlist) {
|
synchronized(this) {
|
||||||
playlistMap.remove(playlist.uid)
|
requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" }
|
||||||
|
}
|
||||||
|
playlistDao.deletePlaylist(playlist.uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
override suspend fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
|
||||||
override fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
|
|
||||||
val playlistImpl =
|
val playlistImpl =
|
||||||
requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" }
|
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
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface UserModule {
|
interface UserModule {
|
||||||
@Binds fun userLibaryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory
|
@Binds fun userLibraryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
class UserRoomModule {
|
class UserRoomModule {
|
||||||
@Provides fun playlistDao(database: PlaylistDatabase) = database.playlistDao()
|
@Provides fun playlistDao(database: UserMusicDatabase) = database.playlistDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun playlistDatabase(@ApplicationContext context: Context) =
|
fun userMusicDatabase(@ApplicationContext context: Context) =
|
||||||
Room.databaseBuilder(
|
Room.databaseBuilder(
|
||||||
context.applicationContext, PlaylistDatabase::class.java, "playlists.db")
|
context.applicationContext, UserMusicDatabase::class.java, "user_music.db")
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.fallbackToDestructiveMigrationFrom(0)
|
.fallbackToDestructiveMigrationFrom(0)
|
||||||
.fallbackToDestructiveMigrationOnDowngrade()
|
.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.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
|
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.navigation.MainNavigationAction
|
import org.oxycblt.auxio.navigation.MainNavigationAction
|
||||||
|
@ -51,6 +52,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
* available controls.
|
* available controls.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*
|
||||||
|
* TODO: Improve flickering situation on play button
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class PlaybackPanelFragment :
|
class PlaybackPanelFragment :
|
||||||
|
@ -58,6 +61,7 @@ class PlaybackPanelFragment :
|
||||||
Toolbar.OnMenuItemClickListener,
|
Toolbar.OnMenuItemClickListener,
|
||||||
StyledSeekBar.Listener {
|
StyledSeekBar.Listener {
|
||||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
private val musicModel: MusicViewModel by activityViewModels()
|
||||||
private val navModel: NavigationViewModel by activityViewModels()
|
private val navModel: NavigationViewModel by activityViewModels()
|
||||||
private var equalizerLauncher: ActivityResultLauncher<Intent>? = null
|
private var equalizerLauncher: ActivityResultLauncher<Intent>? = null
|
||||||
|
|
||||||
|
@ -165,6 +169,10 @@ class PlaybackPanelFragment :
|
||||||
navigateToCurrentAlbum()
|
navigateToCurrentAlbum()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_playlist_add -> {
|
||||||
|
playbackModel.song.value?.let(musicModel::addToPlaylist)
|
||||||
|
true
|
||||||
|
}
|
||||||
R.id.action_song_detail -> {
|
R.id.action_song_detail -> {
|
||||||
playbackModel.song.value?.let { song ->
|
playbackModel.song.value?.let { song ->
|
||||||
navModel.mainNavigateTo(
|
navModel.mainNavigateTo(
|
||||||
|
|
|
@ -306,16 +306,14 @@ constructor(
|
||||||
"Song to play not in parent"
|
"Song to play not in parent"
|
||||||
}
|
}
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||||
val sort =
|
val queue =
|
||||||
when (parent) {
|
when (parent) {
|
||||||
is Genre -> musicSettings.genreSongSort
|
is Genre -> musicSettings.genreSongSort.songs(parent.songs)
|
||||||
is Artist -> musicSettings.artistSongSort
|
is Artist -> musicSettings.artistSongSort.songs(parent.songs)
|
||||||
is Album -> musicSettings.albumSongSort
|
is Album -> musicSettings.albumSongSort.songs(parent.songs)
|
||||||
is Playlist -> musicSettings.playlistSongSort
|
is Playlist -> parent.songs
|
||||||
null -> musicSettings.songSort
|
null -> musicSettings.songSort.songs(deviceLibrary.songs)
|
||||||
}
|
}
|
||||||
val songs = parent?.songs ?: deviceLibrary.songs
|
|
||||||
val queue = sort.songs(songs)
|
|
||||||
playbackManager.play(song, parent, queue, shuffled)
|
playbackManager.play(song, parent, queue, shuffled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -394,7 +392,7 @@ constructor(
|
||||||
* @param playlist The [Playlist] to add.
|
* @param playlist The [Playlist] to add.
|
||||||
*/
|
*/
|
||||||
fun playNext(playlist: Playlist) {
|
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.
|
* @param playlist The [Playlist] to add.
|
||||||
*/
|
*/
|
||||||
fun addToQueue(playlist: Playlist) {
|
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
|
else -> Queue.Change.Type.MAPPING
|
||||||
}
|
}
|
||||||
check()
|
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 androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
|
import org.oxycblt.auxio.databinding.ItemEditableSongBinding
|
||||||
import org.oxycblt.auxio.list.EditableListListener
|
import org.oxycblt.auxio.list.EditClickListListener
|
||||||
import org.oxycblt.auxio.list.adapter.*
|
import org.oxycblt.auxio.list.adapter.*
|
||||||
|
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
|
||||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
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.
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class QueueAdapter(private val listener: EditableListListener<Song>) :
|
class QueueAdapter(private val listener: EditClickListListener<Song>) :
|
||||||
FlexibleListAdapter<Song, QueueSongViewHolder>(QueueSongViewHolder.DIFF_CALLBACK) {
|
FlexibleListAdapter<Song, QueueSongViewHolder>(QueueSongViewHolder.DIFF_CALLBACK) {
|
||||||
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this
|
// 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
|
// 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
|
* A [PlayingIndicatorAdapter.ViewHolder] that displays an queue [Song] which can be re-ordered and
|
||||||
* instance.
|
* removed. Use [from] to create an instance.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class QueueSongViewHolder private constructor(private val binding: ItemQueueSongBinding) :
|
class QueueSongViewHolder private constructor(private val binding: ItemEditableSongBinding) :
|
||||||
PlayingIndicatorAdapter.ViewHolder(binding.root) {
|
PlayingIndicatorAdapter.ViewHolder(binding.root), MaterialDragCallback.ViewHolder {
|
||||||
/** The "body" view of this [QueueSongViewHolder] that shows the [Song] information. */
|
override val enabled = true
|
||||||
val bodyView: View
|
override val root = binding.root
|
||||||
get() = binding.body
|
override val body = binding.body
|
||||||
|
override val delete = binding.background
|
||||||
/** The background view of this [QueueSongViewHolder] that shows the delete icon. */
|
override val background =
|
||||||
val backgroundView: View
|
|
||||||
get() = binding.background
|
|
||||||
|
|
||||||
/** The actual background drawable of this [QueueSongViewHolder] that can be manipulated. */
|
|
||||||
val backgroundDrawable =
|
|
||||||
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
|
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
|
||||||
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
|
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
|
||||||
elevation = binding.context.getDimen(R.dimen.elevation_normal) * 5
|
elevation = binding.context.getDimen(R.dimen.elevation_normal) * 5
|
||||||
alpha = 0
|
alpha = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** If this queue item is considered "in the future" (i.e has not played yet). */
|
|
||||||
var isFuture: Boolean
|
var isFuture: Boolean
|
||||||
get() = binding.songAlbumCover.isEnabled
|
get() = binding.songAlbumCover.isEnabled
|
||||||
set(value) {
|
set(value) {
|
||||||
// Don't want to disable clicking, just indicate the body and handle is disabled
|
|
||||||
binding.songAlbumCover.isEnabled = value
|
binding.songAlbumCover.isEnabled = value
|
||||||
binding.songName.isEnabled = value
|
binding.songName.isEnabled = value
|
||||||
binding.songInfo.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)
|
fillColor = binding.context.getAttrColorCompat(R.attr.colorSurface)
|
||||||
elevation = binding.context.getDimen(R.dimen.elevation_normal)
|
elevation = binding.context.getDimen(R.dimen.elevation_normal)
|
||||||
},
|
},
|
||||||
backgroundDrawable))
|
background))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bind new data to this instance.
|
* Bind new data to this instance.
|
||||||
*
|
*
|
||||||
* @param song The new [Song] to bind.
|
* @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")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
fun bind(song: Song, listener: EditableListListener<Song>) {
|
fun bind(song: Song, listener: EditClickListListener<Song>) {
|
||||||
listener.bind(song, this, bodyView, binding.songDragHandle)
|
listener.bind(song, this, body, binding.songDragHandle)
|
||||||
binding.songAlbumCover.bind(song)
|
binding.songAlbumCover.bind(song)
|
||||||
binding.songName.text = song.name.resolve(binding.context)
|
binding.songName.text = song.name.resolve(binding.context)
|
||||||
binding.songInfo.text = song.artists.resolveNames(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.
|
* @return A new instance.
|
||||||
*/
|
*/
|
||||||
fun from(parent: View) =
|
fun from(parent: View) =
|
||||||
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
|
QueueSongViewHolder(ItemEditableSongBinding.inflate(parent.context.inflater))
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK
|
val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK
|
||||||
|
|
|
@ -18,15 +18,9 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.playback.queue
|
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.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
|
||||||
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 the queue UI,
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() {
|
class QueueDragCallback(private val queueModel: QueueViewModel) : MaterialDragCallback() {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMove(
|
override fun onMove(
|
||||||
recyclerView: RecyclerView,
|
recyclerView: RecyclerView,
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
target: RecyclerView.ViewHolder
|
target: RecyclerView.ViewHolder
|
||||||
): Boolean {
|
) =
|
||||||
logD("${viewHolder.bindingAdapterPosition} ${target.bindingAdapterPosition}")
|
queueModel.moveQueueDataItems(
|
||||||
return playbackModel.moveQueueDataItems(
|
|
||||||
viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
|
viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
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 dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import org.oxycblt.auxio.databinding.FragmentQueueBinding
|
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.music.Song
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||||
|
@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.collectImmediately
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener<Song> {
|
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditClickListListener<Song> {
|
||||||
private val queueModel: QueueViewModel by activityViewModels()
|
private val queueModel: QueueViewModel by activityViewModels()
|
||||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
private val queueAdapter = QueueAdapter(this)
|
private val queueAdapter = QueueAdapter(this)
|
||||||
|
|
|
@ -534,17 +534,24 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
||||||
val internalPlayer = internalPlayer ?: return
|
val internalPlayer = internalPlayer ?: return
|
||||||
logD("Restoring state $savedState")
|
logD("Restoring state $savedState")
|
||||||
|
|
||||||
|
val lastSong = queue.currentSong
|
||||||
parent = savedState.parent
|
parent = savedState.parent
|
||||||
queue.applySavedState(savedState.queueState)
|
queue.applySavedState(savedState.queueState)
|
||||||
repeatMode = savedState.repeatMode
|
repeatMode = savedState.repeatMode
|
||||||
notifyNewPlayback()
|
notifyNewPlayback()
|
||||||
|
|
||||||
// Continuing playback while also possibly doing drastic state updates is
|
// Check if we need to reload the player with a new music file, or if we can just leave
|
||||||
// a bad idea, so pause.
|
// it be. Specifically done so we don't pause on music updates that don't really change
|
||||||
internalPlayer.loadSong(queue.currentSong, false)
|
// what's playing (ex. playlist editing)
|
||||||
if (queue.currentSong != null) {
|
if (lastSong != queue.currentSong) {
|
||||||
// Internal player may have reloaded the media item, re-seek to the previous position
|
// Continuing playback while also possibly doing drastic state updates is
|
||||||
seekTo(savedState.positionMs)
|
// 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
|
isInitialized = true
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,7 +81,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
||||||
|
|
||||||
imm = binding.context.getSystemServiceCompat(InputMethodManager::class)
|
imm = binding.context.getSystemServiceCompat(InputMethodManager::class)
|
||||||
|
|
||||||
binding.searchToolbar.apply {
|
binding.searchNormalToolbar.apply {
|
||||||
// Initialize the current filtering mode.
|
// Initialize the current filtering mode.
|
||||||
menu.findItem(searchModel.getFilterOptionId()).isChecked = true
|
menu.findItem(searchModel.getFilterOptionId()).isChecked = true
|
||||||
|
|
||||||
|
@ -110,7 +110,10 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
||||||
binding.searchRecycler.apply {
|
binding.searchRecycler.apply {
|
||||||
adapter = searchAdapter
|
adapter = searchAdapter
|
||||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
(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
|
item is Divider || item is Header
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -126,7 +129,7 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentSearchBinding) {
|
override fun onDestroyBinding(binding: FragmentSearchBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.searchToolbar.setOnMenuItemClickListener(null)
|
binding.searchNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
binding.searchRecycler.adapter = null
|
binding.searchRecycler.adapter = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,10 +201,16 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
||||||
|
|
||||||
private fun updateSelection(selected: List<Music>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
searchAdapter.setSelected(selected.toSet())
|
searchAdapter.setSelected(selected.toSet())
|
||||||
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
|
val binding = requireBinding()
|
||||||
selected.isNotEmpty()) {
|
if (selected.isNotEmpty()) {
|
||||||
// Make selection of obscured items easier by hiding the keyboard.
|
binding.searchSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
|
||||||
hideKeyboard()
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?attr/colorSurface" />
|
<item android:drawable="@drawable/sel_selection_bg" />
|
||||||
<item android:drawable="@drawable/sel_item_ripple_bg" />
|
|
||||||
<item android:drawable="?attr/selectableItemBackground" />
|
<item android:drawable="?attr/selectableItemBackground" />
|
||||||
</layer-list>
|
</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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?attr/colorSurface" />
|
|
||||||
<item android:drawable="?attr/selectableItemBackground" />
|
<item android:drawable="?attr/selectableItemBackground" />
|
||||||
</layer-list>
|
</layer-list>
|
|
@ -13,19 +13,38 @@
|
||||||
app:liftOnScroll="true"
|
app:liftOnScroll="true"
|
||||||
app:liftOnScrollTargetViewId="@id/detail_recycler">
|
app:liftOnScrollTargetViewId="@id/detail_recycler">
|
||||||
|
|
||||||
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay
|
<org.oxycblt.auxio.ui.MultiToolbar
|
||||||
android:id="@+id/detail_selection_toolbar"
|
android:id="@+id/detail_toolbar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<com.google.android.material.appbar.MaterialToolbar
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
android:id="@+id/detail_toolbar"
|
android:id="@+id/detail_normal_toolbar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
app:navigationIcon="@drawable/ic_back_24" />
|
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>
|
</org.oxycblt.auxio.detail.DetailAppBarLayout>
|
||||||
|
|
||||||
|
|
|
@ -12,20 +12,29 @@
|
||||||
android:id="@+id/home_appbar"
|
android:id="@+id/home_appbar"
|
||||||
style="@style/Widget.Auxio.AppBarLayout">
|
style="@style/Widget.Auxio.AppBarLayout">
|
||||||
|
|
||||||
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay
|
<org.oxycblt.auxio.ui.MultiToolbar
|
||||||
android:id="@+id/home_selection_toolbar"
|
android:id="@+id/home_toolbar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<com.google.android.material.appbar.MaterialToolbar
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
android:id="@+id/home_toolbar"
|
android:id="@+id/home_normal_toolbar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
app:layout_scrollFlags="scroll|enterAlways"
|
app:layout_scrollFlags="scroll|enterAlways"
|
||||||
app:menu="@menu/menu_home"
|
app:menu="@menu/menu_home"
|
||||||
app:title="@string/info_app_name" />
|
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
|
<com.google.android.material.tabs.TabLayout
|
||||||
android:id="@+id/home_tabs"
|
android:id="@+id/home_tabs"
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
style="@style/Widget.Auxio.RecyclerView.Linear"
|
style="@style/Widget.Auxio.RecyclerView.Linear"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="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
|
<com.google.android.material.divider.MaterialDivider
|
||||||
android:id="@+id/queue_divider"
|
android:id="@+id/queue_divider"
|
||||||
|
|
|
@ -12,13 +12,13 @@
|
||||||
app:liftOnScroll="true"
|
app:liftOnScroll="true"
|
||||||
app:liftOnScrollTargetViewId="@id/search_recycler">
|
app:liftOnScrollTargetViewId="@id/search_recycler">
|
||||||
|
|
||||||
<org.oxycblt.auxio.list.selection.SelectionToolbarOverlay
|
<org.oxycblt.auxio.ui.MultiToolbar
|
||||||
android:id="@+id/search_selection_toolbar"
|
android:id="@+id/search_toolbar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<com.google.android.material.appbar.MaterialToolbar
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
android:id="@+id/search_toolbar"
|
android:id="@+id/search_normal_toolbar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
app:menu="@menu/menu_search"
|
app:menu="@menu/menu_search"
|
||||||
|
@ -49,7 +49,16 @@
|
||||||
|
|
||||||
</com.google.android.material.appbar.MaterialToolbar>
|
</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>
|
</org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="@drawable/ui_item_ripple"
|
android:background="@drawable/ui_item_bg"
|
||||||
android:paddingStart="@dimen/spacing_medium"
|
android:paddingStart="@dimen/spacing_medium"
|
||||||
android:paddingTop="@dimen/spacing_mid_medium"
|
android:paddingTop="@dimen/spacing_mid_medium"
|
||||||
android:paddingEnd="@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_height="wrap_content"
|
||||||
android:layout_gravity="end|center_vertical"
|
android:layout_gravity="end|center_vertical"
|
||||||
android:layout_marginEnd="@dimen/spacing_small"
|
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:padding="@dimen/spacing_medium"
|
||||||
android:src="@drawable/ic_delete_24"
|
android:src="@drawable/ic_delete_24"
|
||||||
app:tint="?attr/colorOnError" />
|
app:tint="?attr/colorOnError" />
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
android:id="@+id/interact_body"
|
android:id="@+id/interact_body"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/selectableItemBackground">
|
android:background="@drawable/ui_item_ripple">
|
||||||
|
|
||||||
<org.oxycblt.auxio.image.ImageGroup
|
<org.oxycblt.auxio.image.ImageGroup
|
||||||
android:id="@+id/song_album_cover"
|
android:id="@+id/song_album_cover"
|
||||||
|
@ -79,12 +79,25 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginEnd="@dimen/spacing_mid_medium"
|
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:icon="@drawable/ic_handle_24"
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/song_album_cover"
|
app:layout_constraintBottom_toBottomOf="@+id/song_album_cover"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="@+id/song_album_cover" />
|
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>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
|
@ -4,7 +4,7 @@
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="@drawable/ui_item_ripple"
|
android:background="@drawable/ui_item_bg"
|
||||||
android:paddingStart="@dimen/spacing_medium"
|
android:paddingStart="@dimen/spacing_medium"
|
||||||
android:paddingTop="@dimen/spacing_mid_medium"
|
android:paddingTop="@dimen/spacing_mid_medium"
|
||||||
android:paddingEnd="@dimen/spacing_mid_medium"
|
android:paddingEnd="@dimen/spacing_mid_medium"
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="@drawable/ui_item_ripple"
|
android:background="@drawable/ui_item_bg"
|
||||||
android:paddingStart="@dimen/spacing_medium"
|
android:paddingStart="@dimen/spacing_medium"
|
||||||
android:paddingTop="@dimen/spacing_mid_medium"
|
android:paddingTop="@dimen/spacing_mid_medium"
|
||||||
android:paddingEnd="@dimen/spacing_mid_medium"
|
android:paddingEnd="@dimen/spacing_mid_medium"
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
@ -20,7 +19,7 @@
|
||||||
tools:text="Songs" />
|
tools:text="Songs" />
|
||||||
|
|
||||||
<org.oxycblt.auxio.ui.RippleFixMaterialButton
|
<org.oxycblt.auxio.ui.RippleFixMaterialButton
|
||||||
android:id="@+id/header_button"
|
android:id="@+id/header_sort"
|
||||||
style="@style/Widget.Auxio.Button.Icon.Small"
|
style="@style/Widget.Auxio.Button.Icon.Small"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|
|
@ -12,4 +12,7 @@
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_share"
|
android:id="@+id/action_share"
|
||||||
android:title="@string/lbl_share" />
|
android:title="@string/lbl_share" />
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_playlist_add"
|
||||||
|
android:title="@string/lbl_playlist_add" />
|
||||||
</menu>
|
</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
|
<item
|
||||||
android:id="@+id/action_share"
|
android:id="@+id/action_share"
|
||||||
android:title="@string/lbl_share" />
|
android:title="@string/lbl_share" />
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_playlist_add"
|
||||||
|
android:title="@string/lbl_playlist_add" />
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_song_detail"
|
android:id="@+id/action_song_detail"
|
||||||
android:title="@string/lbl_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_change_repeat">تغيير وضع التكرار</string>
|
||||||
<string name="desc_shuffle">تشغيل او اطفاء الخلط</string>
|
<string name="desc_shuffle">تشغيل او اطفاء الخلط</string>
|
||||||
<string name="desc_shuffle_all">خلط جميع الاغاني</string>
|
<string name="desc_shuffle_all">خلط جميع الاغاني</string>
|
||||||
<string name="desc_clear_queue_item">إزالة اغنية من الطابور</string>
|
<string name="desc_remove_song">إزالة اغنية من الطابور</string>
|
||||||
<string name="desc_queue_handle">نقل اغنية من الطابور</string>
|
<string name="desc_song_handle">نقل اغنية من الطابور</string>
|
||||||
<string name="desc_tab_handle">تحريك التبويت</string>
|
<string name="desc_tab_handle">تحريك التبويت</string>
|
||||||
<string name="desc_clear_search">إزالة كلمة البحث</string>
|
<string name="desc_clear_search">إزالة كلمة البحث</string>
|
||||||
<string name="desc_music_dir_delete">إزالة المجلد المستبعد</string>
|
<string name="desc_music_dir_delete">إزالة المجلد المستبعد</string>
|
||||||
|
|
|
@ -144,7 +144,7 @@
|
||||||
<string name="err_bad_dir">Гэтая папка не падтрымліваецца</string>
|
<string name="err_bad_dir">Гэтая папка не падтрымліваецца</string>
|
||||||
<string name="err_did_not_restore">Немагчыма аднавіць стан</string>
|
<string name="err_did_not_restore">Немагчыма аднавіць стан</string>
|
||||||
<string name="desc_track_number">Кампазіцыя %d</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="err_no_app">Не знойдзена прыкладання, якое можа справіцца з гэтай задачай</string>
|
||||||
<string name="desc_play_pause">Прайграванне або прыпыненне</string>
|
<string name="desc_play_pause">Прайграванне або прыпыненне</string>
|
||||||
<string name="err_did_not_save">Немагчыма захаваць стан</string>
|
<string name="err_did_not_save">Немагчыма захаваць стан</string>
|
||||||
|
@ -153,7 +153,7 @@
|
||||||
<string name="desc_change_repeat">Змяніць рэжым паўтору</string>
|
<string name="desc_change_repeat">Змяніць рэжым паўтору</string>
|
||||||
<string name="desc_auxio_icon">Значок Auxio</string>
|
<string name="desc_auxio_icon">Значок Auxio</string>
|
||||||
<string name="desc_shuffle">Уключыце або выключыце перамешванне</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_shuffle_all">Перамяшаць усе песні</string>
|
||||||
<string name="desc_exit">Спыніць прайграванне</string>
|
<string name="desc_exit">Спыніць прайграванне</string>
|
||||||
<string name="desc_queue_bar">Адкрыйце чаргу</string>
|
<string name="desc_queue_bar">Адкрыйце чаргу</string>
|
||||||
|
@ -278,4 +278,8 @@
|
||||||
<string name="desc_new_playlist">Стварыце новы плэйліст</string>
|
<string name="desc_new_playlist">Стварыце новы плэйліст</string>
|
||||||
<string name="fmt_def_playlist">Плэйліст %d</string>
|
<string name="fmt_def_playlist">Плэйліст %d</string>
|
||||||
<string name="lbl_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="def_song_count">Без трэкаў</string>
|
||||||
</resources>
|
</resources>
|
|
@ -109,8 +109,8 @@
|
||||||
<string name="desc_change_repeat">Změnit režim opakování</string>
|
<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">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_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_remove_song">Odebrat tuto skladbu z fronty</string>
|
||||||
<string name="desc_queue_handle">Přesunout tuto skladbu ve frontě</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_tab_handle">Přesunout tuto kartu</string>
|
||||||
<string name="desc_clear_search">Vymazat hledání</string>
|
<string name="desc_clear_search">Vymazat hledání</string>
|
||||||
<string name="desc_music_dir_delete">Odebrat složku</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="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="lbl_none">Žádné</string>
|
||||||
<string name="desc_new_playlist">Vytvořit nový playlist</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>
|
</resources>
|
|
@ -125,7 +125,7 @@
|
||||||
<string name="set_repeat_pause">Pause bei Wiederholung</string>
|
<string name="set_repeat_pause">Pause bei Wiederholung</string>
|
||||||
<string name="set_repeat_pause_desc">Pausieren, wenn ein Song wiederholt wird</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_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_music_dir_delete">Verzechnis entfernen</string>
|
||||||
<string name="desc_no_cover">Albumcover</string>
|
<string name="desc_no_cover">Albumcover</string>
|
||||||
<string name="def_playback">Keine Musik wird gespielt</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="set_lib_tabs_desc">Sichtbarkeit und Ordnung der Bibliotheksregisterkarten ändern</string>
|
||||||
<string name="lbl_name">Name</string>
|
<string name="lbl_name">Name</string>
|
||||||
<string name="desc_shuffle_all">Alle Lieder zufällig</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="desc_tab_handle">Tab versetzen</string>
|
||||||
<string name="def_artist">Unbekannter Künstler</string>
|
<string name="def_artist">Unbekannter Künstler</string>
|
||||||
<string name="lbl_duration">Dauer</string>
|
<string name="lbl_duration">Dauer</string>
|
||||||
|
@ -271,11 +271,24 @@
|
||||||
<string name="set_state">Persistenz</string>
|
<string name="set_state">Persistenz</string>
|
||||||
<string name="set_replay_gain">Lautstärkeanpassung ReplayGain</string>
|
<string name="set_replay_gain">Lautstärkeanpassung ReplayGain</string>
|
||||||
<string name="lbl_sort_dec">Absteigend</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_playlist">Wiedergabeliste</string>
|
||||||
<string name="lbl_playlists">Wiedergabelisten</string>
|
<string name="lbl_playlists">Wiedergabelisten</string>
|
||||||
<string name="set_intelligent_sorting">Artikel beim Sortieren ignorieren</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="set_intelligent_sorting_desc">Wörter wie „the“ ignorieren (funktioniert am besten mit englischsprachiger Musik)</string>
|
||||||
<string name="lbl_none">Keine</string>
|
<string name="lbl_none">Keine</string>
|
||||||
<string name="desc_new_playlist">Neue Wiedergabeliste erstellen</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>
|
</resources>
|
|
@ -91,8 +91,8 @@
|
||||||
<string name="desc_change_repeat">Cambiar modo de repetición</string>
|
<string name="desc_change_repeat">Cambiar modo de repetición</string>
|
||||||
<string name="desc_shuffle">Act/des mezcla</string>
|
<string name="desc_shuffle">Act/des mezcla</string>
|
||||||
<string name="desc_shuffle_all">Mezclar todo</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_remove_song">Quitar canción de la cola</string>
|
||||||
<string name="desc_queue_handle">Mover canción en 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_tab_handle">Mover pestaña</string>
|
||||||
<string name="desc_clear_search">Borrar historial de búsqueda</string>
|
<string name="desc_clear_search">Borrar historial de búsqueda</string>
|
||||||
<string name="desc_music_dir_delete">Quitar carpeta</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">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="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="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>
|
</resources>
|
|
@ -203,8 +203,8 @@
|
||||||
<string name="cdc_aac">Advanced Audio Coding (AAC)</string>
|
<string name="cdc_aac">Advanced Audio Coding (AAC)</string>
|
||||||
<string name="cdc_flac">Free Lossless Audio Codec (FLAC)</string>
|
<string name="cdc_flac">Free Lossless Audio Codec (FLAC)</string>
|
||||||
<string name="clr_red">Vermello</string>
|
<string name="clr_red">Vermello</string>
|
||||||
<string name="desc_clear_queue_item">Quitar esta canción da cola</string>
|
<string name="desc_remove_song">Quitar esta canción da cola</string>
|
||||||
<string name="desc_queue_handle">Mover está canción na 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="desc_tab_handle">Mover esta pestana</string>
|
||||||
<string name="clr_pink">Rosa</string>
|
<string name="clr_pink">Rosa</string>
|
||||||
<string name="clr_purple">Morado</string>
|
<string name="clr_purple">Morado</string>
|
||||||
|
|
|
@ -107,8 +107,8 @@
|
||||||
<string name="desc_track_number">Zvučni zapis %d</string>
|
<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">Omogućite ili onemogućite miješanje</string>
|
||||||
<string name="desc_shuffle_all">Izmiješaj sve pjesme</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_remove_song">Ukoni ovu pjesmu iz popisa pjesama</string>
|
||||||
<string name="desc_queue_handle">Premjesti ovu pjesmu u popisu 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_tab_handle">Pomakni ovu pločicu</string>
|
||||||
<string name="desc_clear_search">Izbriši pretražene pojmove</string>
|
<string name="desc_clear_search">Izbriši pretražene pojmove</string>
|
||||||
<string name="desc_music_dir_delete">Ukloni mapu</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_comma">Zarez (,)</string>
|
||||||
<string name="set_separators_and">Ampersand (&)</string>
|
<string name="set_separators_and">Ampersand (&)</string>
|
||||||
<string name="lbl_compilation_live">Kompilacija uživo</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="lbl_mixes">Kompilacije</string>
|
||||||
<string name="set_separators">Znakovi odjeljivanja vrijednosti</string>
|
<string name="set_separators">Znakovi odjeljivanja vrijednosti</string>
|
||||||
<string name="desc_exit">Prekini reprodukciju</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">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="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="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>
|
</resources>
|
|
@ -139,7 +139,7 @@
|
||||||
<string name="desc_artist_image">Gambar Artis untuk %s</string>
|
<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_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="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="desc_clear_search">Hapus kueri pencarian</string>
|
||||||
<string name="set_pre_amp_without">Penyesuaian tanpa tag</string>
|
<string name="set_pre_amp_without">Penyesuaian tanpa tag</string>
|
||||||
<string name="set_dirs">Folder musik</string>
|
<string name="set_dirs">Folder musik</string>
|
||||||
|
@ -159,7 +159,7 @@
|
||||||
<string name="desc_auxio_icon">Ikon Auxio</string>
|
<string name="desc_auxio_icon">Ikon Auxio</string>
|
||||||
<string name="desc_no_cover">Sampul album</string>
|
<string name="desc_no_cover">Sampul album</string>
|
||||||
<string name="desc_shuffle">Aktifkan atau nonaktifkan acak</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="def_playback">Tidak ada musik yang diputar</string>
|
||||||
<string name="cdc_ogg">Audio Ogg</string>
|
<string name="cdc_ogg">Audio Ogg</string>
|
||||||
<string name="clr_brown">Cokelat</string>
|
<string name="clr_brown">Cokelat</string>
|
||||||
|
|
|
@ -94,8 +94,8 @@
|
||||||
<string name="desc_change_repeat">Cambia modalità ripetizione</string>
|
<string name="desc_change_repeat">Cambia modalità ripetizione</string>
|
||||||
<string name="desc_shuffle">Attiva o disattiva mescolamento</string>
|
<string name="desc_shuffle">Attiva o disattiva mescolamento</string>
|
||||||
<string name="desc_shuffle_all">Mescola tutte le canzoni</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_remove_song">Rimuove questa canzone della coda</string>
|
||||||
<string name="desc_queue_handle">Muove 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_tab_handle">Muove questa scheda</string>
|
||||||
<string name="desc_clear_search">Cancella la query di ricerca</string>
|
<string name="desc_clear_search">Cancella la query di ricerca</string>
|
||||||
<string name="desc_music_dir_delete">Rimuovi cartella</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_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="set_separators_and">E commerciale (&)</string>
|
||||||
<string name="lbl_compilation_live">Raccolte live</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_mixes">Mixes</string>
|
||||||
<string name="lbl_mix">Mix</string>
|
<string name="lbl_mix">Mix</string>
|
||||||
<string name="set_cover_mode_quality">Alta qualità</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="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_new_playlist">Crea una nuova playlist</string>
|
||||||
<string name="desc_playlist_image">Immagine della playlist per %s</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>
|
</resources>
|
|
@ -8,7 +8,7 @@
|
||||||
<string name="lbl_duration">曲の長さ</string>
|
<string name="lbl_duration">曲の長さ</string>
|
||||||
<string name="set_save_desc">現在の再生状態を保存</string>
|
<string name="set_save_desc">現在の再生状態を保存</string>
|
||||||
<string name="desc_tab_handle">このタブを移動</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="def_date">日付けがありません</string>
|
||||||
<string name="lbl_songs">曲</string>
|
<string name="lbl_songs">曲</string>
|
||||||
<string name="lbl_all_songs">すべての曲</string>
|
<string name="lbl_all_songs">すべての曲</string>
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
<string name="set_rewind_prev_desc">前の曲にスキップ前に曲を巻き戻す</string>
|
<string name="set_rewind_prev_desc">前の曲にスキップ前に曲を巻き戻す</string>
|
||||||
<string name="set_dirs">音楽フォルダ</string>
|
<string name="set_dirs">音楽フォルダ</string>
|
||||||
<string name="set_separators_plus">プラス (+)</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_mixes">DJミックス</string>
|
||||||
<string name="lbl_mix">DJミックス</string>
|
<string name="lbl_mix">DJミックス</string>
|
||||||
<string name="lbl_disc">ディスク</string>
|
<string name="lbl_disc">ディスク</string>
|
||||||
|
@ -91,7 +91,7 @@
|
||||||
<string name="err_did_not_restore">再生状態を復元できません</string>
|
<string name="err_did_not_restore">再生状態を復元できません</string>
|
||||||
<string name="desc_track_number">トラック %d</string>
|
<string name="desc_track_number">トラック %d</string>
|
||||||
<string name="desc_play_pause">再生またはポーズ</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_music_dir_delete">フォルダを除去</string>
|
||||||
<string name="desc_auxio_icon">Auxio アイコン</string>
|
<string name="desc_auxio_icon">Auxio アイコン</string>
|
||||||
<string name="desc_no_cover">アルバムカバー</string>
|
<string name="desc_no_cover">アルバムカバー</string>
|
||||||
|
@ -266,4 +266,11 @@
|
||||||
<string name="lbl_playlists">プレイリスト</string>
|
<string name="lbl_playlists">プレイリスト</string>
|
||||||
<string name="desc_playlist_image">%s のプレイリスト イメージ</string>
|
<string name="desc_playlist_image">%s のプレイリスト イメージ</string>
|
||||||
<string name="lbl_none">無し</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>
|
</resources>
|
|
@ -107,8 +107,8 @@
|
||||||
<string name="desc_change_repeat">반복 방식 변경</string>
|
<string name="desc_change_repeat">반복 방식 변경</string>
|
||||||
<string name="desc_shuffle">무작위 재생 켜기 또는 끄기</string>
|
<string name="desc_shuffle">무작위 재생 켜기 또는 끄기</string>
|
||||||
<string name="desc_shuffle_all">모든 곡 무작위 재생</string>
|
<string name="desc_shuffle_all">모든 곡 무작위 재생</string>
|
||||||
<string name="desc_clear_queue_item">이 대기열의 곡 제거</string>
|
<string name="desc_remove_song">이 대기열의 곡 제거</string>
|
||||||
<string name="desc_queue_handle">이 대기열의 곡 이동</string>
|
<string name="desc_song_handle">이 대기열의 곡 이동</string>
|
||||||
<string name="desc_tab_handle">이 탭 이동</string>
|
<string name="desc_tab_handle">이 탭 이동</string>
|
||||||
<string name="desc_clear_search">검색 기록 삭제</string>
|
<string name="desc_clear_search">검색 기록 삭제</string>
|
||||||
<string name="desc_music_dir_delete">폴더 제거</string>
|
<string name="desc_music_dir_delete">폴더 제거</string>
|
||||||
|
@ -174,7 +174,7 @@
|
||||||
<string name="fmt_sample_rate">%d Hz</string>
|
<string name="fmt_sample_rate">%d Hz</string>
|
||||||
<string name="lbl_mix">믹스</string>
|
<string name="lbl_mix">믹스</string>
|
||||||
<string name="lbl_compilation_live">라이브 컴필레이션</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_mixes">믹스</string>
|
||||||
<string name="lbl_equalizer">이퀄라이저</string>
|
<string name="lbl_equalizer">이퀄라이저</string>
|
||||||
<string name="lbl_shuffle_shortcut_short">셔플</string>
|
<string name="lbl_shuffle_shortcut_short">셔플</string>
|
||||||
|
@ -278,4 +278,10 @@
|
||||||
<string name="set_intelligent_sorting_desc">이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함)</string>
|
<string name="set_intelligent_sorting_desc">이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함)</string>
|
||||||
<string name="lbl_none">없음</string>
|
<string name="lbl_none">없음</string>
|
||||||
<string name="desc_new_playlist">새 재생 목록 만들기</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>
|
</resources>
|
|
@ -138,7 +138,7 @@
|
||||||
<string name="set_replay_gain_mode_dynamic">Pageidaujamas albumui, jei vienas groja</string>
|
<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="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_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="desc_tab_handle">Perkelti šį skirtuką</string>
|
||||||
<string name="err_index_failed">Muzikos krovimas nepavyko</string>
|
<string name="err_index_failed">Muzikos krovimas nepavyko</string>
|
||||||
<string name="err_no_perms">„Auxio“ reikia leidimo skaityti jūsų muzikos biblioteką</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="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_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="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_songs">Groti iš visų dainų</string>
|
||||||
<string name="set_playback_mode_none">Groti iš parodyto elemento</string>
|
<string name="set_playback_mode_none">Groti iš parodyto elemento</string>
|
||||||
<string name="set_playback_mode_album">Groti iš albumo</string>
|
<string name="set_playback_mode_album">Groti iš albumo</string>
|
||||||
|
|
|
@ -65,8 +65,8 @@
|
||||||
<string name="def_playback">സംഗീതം കളിക്കുന്നില്ല</string>
|
<string name="def_playback">സംഗീതം കളിക്കുന്നില്ല</string>
|
||||||
<string name="clr_yellow">മഞ്ഞ</string>
|
<string name="clr_yellow">മഞ്ഞ</string>
|
||||||
<string name="fmt_selected">%d തിരഞ്ഞെടുത്തു</string>
|
<string name="fmt_selected">%d തിരഞ്ഞെടുത്തു</string>
|
||||||
<string name="desc_clear_queue_item">വരിയിലെ ഈ ഗാനം നീക്കം ചെയ്യുക</string>
|
<string name="desc_remove_song">വരിയിലെ ഈ ഗാനം നീക്കം ചെയ്യുക</string>
|
||||||
<string name="desc_queue_handle">വരിയിലെ ഈ ഗാനം നീക്കുക</string>
|
<string name="desc_song_handle">വരിയിലെ ഈ ഗാനം നീക്കുക</string>
|
||||||
<string name="lbl_reset">പുനഃസജ്ജമാക്കുക</string>
|
<string name="lbl_reset">പുനഃസജ്ജമാക്കുക</string>
|
||||||
<string name="clr_brown">തവിട്ട്</string>
|
<string name="clr_brown">തവിട്ട്</string>
|
||||||
<string name="fmt_list">%1$s, %2$s</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_state">Afspeelstatus herstellen</string>
|
||||||
<string name="set_restore_desc">Herstel de eerder opgeslagen afspeelstatus (indien aanwezig)</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="err_did_not_restore">Geen staat kan hersteld worden</string>
|
||||||
<string name="desc_clear_queue_item">Verwijder dit wachtrij liedje</string>
|
<string name="desc_remove_song">Verwijder dit wachtrij liedje</string>
|
||||||
<string name="desc_queue_handle">Verplaats 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_tab_handle">Verplaats deze tab</string>
|
||||||
<string name="desc_no_cover">Album cover</string>
|
<string name="desc_no_cover">Album cover</string>
|
||||||
<string name="def_track">Geen tracknummer</string>
|
<string name="def_track">Geen tracknummer</string>
|
||||||
|
|
|
@ -196,8 +196,8 @@
|
||||||
<string name="set_library">ਲਾਇਬ੍ਰੇਰੀ</string>
|
<string name="set_library">ਲਾਇਬ੍ਰੇਰੀ</string>
|
||||||
<string name="set_dirs">ਸੰਗੀਤ ਫੋਲਡਰ</string>
|
<string name="set_dirs">ਸੰਗੀਤ ਫੋਲਡਰ</string>
|
||||||
<string name="desc_queue_bar">ਕਤਾਰ ਖੋਲ੍ਹੋ</string>
|
<string name="desc_queue_bar">ਕਤਾਰ ਖੋਲ੍ਹੋ</string>
|
||||||
<string name="desc_clear_queue_item">ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਹਟਾਓ</string>
|
<string name="desc_remove_song">ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਹਟਾਓ</string>
|
||||||
<string name="desc_queue_handle">ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਮੂਵ ਕਰੋ</string>
|
<string name="desc_song_handle">ਇਸ ਕਤਾਰ ਗੀਤ ਨੂੰ ਮੂਵ ਕਰੋ</string>
|
||||||
<string name="desc_change_repeat">ਦੁਹਰਾਓ ਮੋਡ ਬਦਲੋ</string>
|
<string name="desc_change_repeat">ਦੁਹਰਾਓ ਮੋਡ ਬਦਲੋ</string>
|
||||||
<string name="desc_shuffle">ਸ਼ਫਲ ਚਾਲੂ ਜਾਂ ਬੰਦ ਕਰੋ</string>
|
<string name="desc_shuffle">ਸ਼ਫਲ ਚਾਲੂ ਜਾਂ ਬੰਦ ਕਰੋ</string>
|
||||||
<string name="desc_shuffle_all">ਸਾਰੇ ਗੀਤਾਂ ਨੂੰ ਸ਼ਫਲ ਕਰੋ</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_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">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="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_replay_gain_mode_album">Preferuj album</string>
|
||||||
<string name="set_observing">Automatycznie odśwież</string>
|
<string name="set_observing">Automatycznie odśwież</string>
|
||||||
<string name="cdc_flac">FLAC</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_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_exclude">Wyklucz</string>
|
||||||
<string name="set_dirs_mode_include">Zawrzyj</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_tab_handle">Przesuń kartę</string>
|
||||||
<string name="desc_artist_image">Wizerunek wykonawcy dla %s</string>
|
<string name="desc_artist_image">Wizerunek wykonawcy dla %s</string>
|
||||||
<string name="lng_indexing">Ładuję bibliotekę muzyczną…</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="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="lbl_none">Brak</string>
|
||||||
<string name="desc_new_playlist">Utwórz nową playlistę</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>
|
</resources>
|
|
@ -123,7 +123,7 @@
|
||||||
<string name="desc_skip_prev">Pular para a música anterior</string>
|
<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_change_repeat">Alterar o modo de repetição</string>
|
||||||
<string name="desc_shuffle_all">Aleatorizar todas das músicas</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_clear_search">Limpar histórico de pesquisa</string>
|
||||||
<string name="desc_album_cover">Capa do álbum para %s</string>
|
<string name="desc_album_cover">Capa do álbum para %s</string>
|
||||||
<string name="desc_tab_handle">Mover esta aba</string>
|
<string name="desc_tab_handle">Mover esta aba</string>
|
||||||
|
@ -147,7 +147,7 @@
|
||||||
<string name="cdc_mka">Áudio Matroska</string>
|
<string name="cdc_mka">Áudio Matroska</string>
|
||||||
<string name="cdc_aac">Codificação de Audio Avançada (AAC)</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="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="clr_dynamic">Dinâmico</string>
|
||||||
<string name="fmt_lib_total_duration">Duração total: %s</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>
|
<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_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_no_dirs">Sem pastas</string>
|
||||||
<string name="err_bad_dir">Esta pasta não é compatível</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="desc_music_dir_delete">Remover pasta</string>
|
||||||
<string name="lbl_compilation_remix">Compilações de remix</string>
|
<string name="lbl_compilation_remix">Compilações de remix</string>
|
||||||
<string name="lbl_compilation_live">Compilação ao vivo</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="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">Ativar ou desativar a reprodução aleatória</string>
|
||||||
<string name="desc_shuffle_all">Embaralhar todas as músicas</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_mka">Áudio Matroska</string>
|
||||||
<string name="cdc_aac">Codificação de Audio Avançada (AAC)</string>
|
<string name="cdc_aac">Codificação de Audio Avançada (AAC)</string>
|
||||||
<string name="lbl_album">Álbum</string>
|
<string name="lbl_album">Álbum</string>
|
||||||
|
|
|
@ -93,8 +93,8 @@
|
||||||
<string name="desc_change_repeat">Режим повтора</string>
|
<string name="desc_change_repeat">Режим повтора</string>
|
||||||
<string name="desc_shuffle">Перемешивание</string>
|
<string name="desc_shuffle">Перемешивание</string>
|
||||||
<string name="desc_shuffle_all">Перемешать все треки</string>
|
<string name="desc_shuffle_all">Перемешать все треки</string>
|
||||||
<string name="desc_clear_queue_item">Удалить трек из очереди</string>
|
<string name="desc_remove_song">Удалить трек из очереди</string>
|
||||||
<string name="desc_queue_handle">Переместить трек в очереди</string>
|
<string name="desc_song_handle">Переместить трек в очереди</string>
|
||||||
<string name="desc_tab_handle">Переместить вкладку</string>
|
<string name="desc_tab_handle">Переместить вкладку</string>
|
||||||
<string name="desc_clear_search">Очистить поисковый запрос</string>
|
<string name="desc_clear_search">Очистить поисковый запрос</string>
|
||||||
<string name="desc_music_dir_delete">Удалить папку</string>
|
<string name="desc_music_dir_delete">Удалить папку</string>
|
||||||
|
@ -287,4 +287,8 @@
|
||||||
<string name="desc_new_playlist">Создать новый плейлист</string>
|
<string name="desc_new_playlist">Создать новый плейлист</string>
|
||||||
<string name="lbl_new_playlist">Новый плейлист</string>
|
<string name="lbl_new_playlist">Новый плейлист</string>
|
||||||
<string name="fmt_def_playlist">Плейлист %d</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>
|
</resources>
|
|
@ -1,2 +1,9 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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_date_added">Eklendiği tarih</string>
|
||||||
<string name="lbl_album_remix">Remix albüm</string>
|
<string name="lbl_album_remix">Remix albüm</string>
|
||||||
<string name="lbl_album_live">Canlı 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_singles">Tekliler</string>
|
||||||
<string name="lbl_single">Tekli</string>
|
<string name="lbl_single">Tekli</string>
|
||||||
<string name="lbl_mixtape">Karışık kaset</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="set_exclude_non_music">Müzik olmayanları hariç tut</string>
|
||||||
<string name="err_did_not_wipe">Durum temizlenemedi</string>
|
<string name="err_did_not_wipe">Durum temizlenemedi</string>
|
||||||
<string name="set_replay_gain_mode">ReplayGain stratejisi</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="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_content_desc">Müzik ve görüntülerin nasıl yükleneceğini denetleyin</string>
|
||||||
<string name="set_music">Müzik</string>
|
<string name="set_music">Müzik</string>
|
||||||
|
|
|
@ -218,8 +218,8 @@
|
||||||
<string name="def_genre">Невідомий жанр</string>
|
<string name="def_genre">Невідомий жанр</string>
|
||||||
<string name="desc_queue_bar">Відкрити чергу</string>
|
<string name="desc_queue_bar">Відкрити чергу</string>
|
||||||
<string name="clr_yellow">Жовтий</string>
|
<string name="clr_yellow">Жовтий</string>
|
||||||
<string name="desc_queue_handle">Перемістити пісню в черзі</string>
|
<string name="desc_song_handle">Перемістити пісню</string>
|
||||||
<string name="desc_clear_queue_item">Видалити пісню з черги</string>
|
<string name="desc_remove_song">Видалити пісню</string>
|
||||||
<string name="clr_cyan">Блакитний</string>
|
<string name="clr_cyan">Блакитний</string>
|
||||||
<string name="clr_teal">Зеленувато-блакитний</string>
|
<string name="clr_teal">Зеленувато-блакитний</string>
|
||||||
<string name="clr_purple">Фіолетовий</string>
|
<string name="clr_purple">Фіолетовий</string>
|
||||||
|
@ -284,4 +284,16 @@
|
||||||
<string name="desc_new_playlist">Створити новий список відтворення</string>
|
<string name="desc_new_playlist">Створити новий список відтворення</string>
|
||||||
<string name="lbl_new_playlist">Новий список відтворення</string>
|
<string name="lbl_new_playlist">Новий список відтворення</string>
|
||||||
<string name="fmt_def_playlist">Список відтворення %d</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>
|
</resources>
|
|
@ -93,8 +93,8 @@
|
||||||
<string name="desc_change_repeat">更改重复播放模式</string>
|
<string name="desc_change_repeat">更改重复播放模式</string>
|
||||||
<string name="desc_shuffle">开启或关闭随机播放模式</string>
|
<string name="desc_shuffle">开启或关闭随机播放模式</string>
|
||||||
<string name="desc_shuffle_all">随机播放所有曲目</string>
|
<string name="desc_shuffle_all">随机播放所有曲目</string>
|
||||||
<string name="desc_clear_queue_item">移除队列曲目</string>
|
<string name="desc_remove_song">移除队列曲目</string>
|
||||||
<string name="desc_queue_handle">移动队列曲目</string>
|
<string name="desc_song_handle">移动队列曲目</string>
|
||||||
<string name="desc_tab_handle">移动该标签</string>
|
<string name="desc_tab_handle">移动该标签</string>
|
||||||
<string name="desc_clear_search">清除搜索队列</string>
|
<string name="desc_clear_search">清除搜索队列</string>
|
||||||
<string name="desc_music_dir_delete">移除文件夹</string>
|
<string name="desc_music_dir_delete">移除文件夹</string>
|
||||||
|
@ -276,4 +276,18 @@
|
||||||
<string name="set_intelligent_sorting">排序时忽略冠词</string>
|
<string name="set_intelligent_sorting">排序时忽略冠词</string>
|
||||||
<string name="set_intelligent_sorting_desc">按名称排序时忽略类似“the”这样的冠词(对英文歌曲的效果最好)</string>
|
<string name="set_intelligent_sorting_desc">按名称排序时忽略类似“the”这样的冠词(对英文歌曲的效果最好)</string>
|
||||||
<string name="desc_new_playlist">创建新的播放列表</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>
|
</resources>
|
|
@ -83,6 +83,7 @@
|
||||||
<string name="lbl_rename_playlist">Rename playlist</string>
|
<string name="lbl_rename_playlist">Rename playlist</string>
|
||||||
<string name="lbl_delete">Delete</string>
|
<string name="lbl_delete">Delete</string>
|
||||||
<string name="lbl_confirm_delete_playlist">Delete playlist?</string>
|
<string name="lbl_confirm_delete_playlist">Delete playlist?</string>
|
||||||
|
<string name="lbl_edit">Edit</string>
|
||||||
|
|
||||||
<!-- Search for music -->
|
<!-- Search for music -->
|
||||||
<string name="lbl_search">Search</string>
|
<string name="lbl_search">Search</string>
|
||||||
|
@ -312,8 +313,8 @@
|
||||||
<string name="desc_new_playlist">Create a new playlist</string>
|
<string name="desc_new_playlist">Create a new playlist</string>
|
||||||
<string name="desc_exit">Stop playback</string>
|
<string name="desc_exit">Stop playback</string>
|
||||||
|
|
||||||
<string name="desc_clear_queue_item">Remove this queue song</string>
|
<string name="desc_remove_song">Remove this song</string>
|
||||||
<string name="desc_queue_handle">Move this queue song</string>
|
<string name="desc_song_handle">Move this song</string>
|
||||||
<string name="desc_queue_bar">Open the queue</string>
|
<string name="desc_queue_bar">Open the queue</string>
|
||||||
<string name="desc_tab_handle">Move this tab</string>
|
<string name="desc_tab_handle">Move this tab</string>
|
||||||
<string name="desc_clear_search">Clear search query</string>
|
<string name="desc_clear_search">Clear search query</string>
|
||||||
|
@ -335,6 +336,7 @@
|
||||||
<string name="def_track">No track</string>
|
<string name="def_track">No track</string>
|
||||||
<string name="def_song_count">No songs</string>
|
<string name="def_song_count">No songs</string>
|
||||||
<string name="def_playback">No music playing</string>
|
<string name="def_playback">No music playing</string>
|
||||||
|
<string name="def_playlists">There\'s nothing here yet</string>
|
||||||
|
|
||||||
<!-- Codec Namespace | Format names -->
|
<!-- Codec Namespace | Format names -->
|
||||||
<eat-comment />
|
<eat-comment />
|
||||||
|
|
|
@ -58,19 +58,23 @@ open class FakeMusicRepository : MusicRepository {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createPlaylist(name: String, songs: List<Song>) {
|
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deletePlaylist(playlist: Playlist) {
|
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
override suspend fun deletePlaylist(playlist: Playlist) {
|
||||||
throw NotImplementedError()
|
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()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue