music: automatically number new playlists
Automatically number new playlists, from Playlist 1, Playlist 2, etc. This comes with the additional requirement that all playlists have unique names.
This commit is contained in:
parent
e01ea25d0b
commit
763061c352
14 changed files with 188 additions and 145 deletions
|
@ -135,7 +135,7 @@ class MainFragment :
|
||||||
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
|
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
|
||||||
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
|
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
|
||||||
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
|
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
|
||||||
collect(musicModel.pendingPlaylistNaming.flow, ::handlePlaylistNaming)
|
collect(musicModel.pendingNewPlaylist.flow, ::handlePlaylistNaming)
|
||||||
collectImmediately(playbackModel.song, ::updateSong)
|
collectImmediately(playbackModel.song, ::updateSong)
|
||||||
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
|
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
|
||||||
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
|
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
|
||||||
|
@ -306,8 +306,8 @@ class MainFragment :
|
||||||
|
|
||||||
private fun handlePlaylistNaming(args: PendingName.Args?) {
|
private fun handlePlaylistNaming(args: PendingName.Args?) {
|
||||||
if (args != null) {
|
if (args != null) {
|
||||||
findNavController().navigateSafe(MainFragmentDirections.actionNamePlaylist(args))
|
findNavController().navigateSafe(MainFragmentDirections.actionNewPlaylist(args))
|
||||||
musicModel.pendingPlaylistNaming.consume()
|
musicModel.pendingNewPlaylist.consume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -138,7 +138,6 @@ class PlaylistDetailFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
// TODO: Handle
|
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -321,7 +321,7 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
|
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
|
||||||
musicModel.createPlaylist("New playlist")
|
musicModel.createPlaylist(requireContext())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlinx.parcelize.IgnoredOnParcel
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
|
@ -118,13 +119,47 @@ sealed interface Music : Item {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Creates an Auxio-style [UID] with a [UUID] generated by internal Auxio code.
|
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
|
||||||
|
* unlikely-to-change metadata of the music.
|
||||||
*
|
*
|
||||||
* @param mode The analogous [MusicMode] of the item that created this [UID].
|
* @param mode The analogous [MusicMode] of the item that created this [UID].
|
||||||
* @param uuid The generated [UUID] for this item.
|
* @param updates Block to update the [MessageDigest] hash with the metadata of the
|
||||||
|
* item. Make sure the metadata hashed semantically aligns with the format
|
||||||
|
* specification.
|
||||||
* @return A new auxio-style [UID].
|
* @return A new auxio-style [UID].
|
||||||
*/
|
*/
|
||||||
fun auxio(mode: MusicMode, uuid: UUID) = UID(Format.AUXIO, mode, uuid)
|
fun auxio(mode: MusicMode, updates: MessageDigest.() -> Unit): UID {
|
||||||
|
val digest =
|
||||||
|
MessageDigest.getInstance("SHA-256").run {
|
||||||
|
updates()
|
||||||
|
digest()
|
||||||
|
}
|
||||||
|
// Convert the digest to a UUID. This does cleave off some of the hash, but this
|
||||||
|
// is considered okay.
|
||||||
|
val uuid =
|
||||||
|
UUID(
|
||||||
|
digest[0]
|
||||||
|
.toLong()
|
||||||
|
.shl(56)
|
||||||
|
.or(digest[1].toLong().and(0xFF).shl(48))
|
||||||
|
.or(digest[2].toLong().and(0xFF).shl(40))
|
||||||
|
.or(digest[3].toLong().and(0xFF).shl(32))
|
||||||
|
.or(digest[4].toLong().and(0xFF).shl(24))
|
||||||
|
.or(digest[5].toLong().and(0xFF).shl(16))
|
||||||
|
.or(digest[6].toLong().and(0xFF).shl(8))
|
||||||
|
.or(digest[7].toLong().and(0xFF)),
|
||||||
|
digest[8]
|
||||||
|
.toLong()
|
||||||
|
.shl(56)
|
||||||
|
.or(digest[9].toLong().and(0xFF).shl(48))
|
||||||
|
.or(digest[10].toLong().and(0xFF).shl(40))
|
||||||
|
.or(digest[11].toLong().and(0xFF).shl(32))
|
||||||
|
.or(digest[12].toLong().and(0xFF).shl(24))
|
||||||
|
.or(digest[13].toLong().and(0xFF).shl(16))
|
||||||
|
.or(digest[14].toLong().and(0xFF).shl(8))
|
||||||
|
.or(digest[15].toLong().and(0xFF)))
|
||||||
|
return UID(Format.AUXIO, mode, uuid)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
|
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
|
||||||
|
|
|
@ -18,11 +18,13 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.dialog.PendingName
|
import org.oxycblt.auxio.music.dialog.PendingName
|
||||||
import org.oxycblt.auxio.util.Event
|
import org.oxycblt.auxio.util.Event
|
||||||
import org.oxycblt.auxio.util.MutableEvent
|
import org.oxycblt.auxio.util.MutableEvent
|
||||||
|
@ -45,8 +47,8 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos
|
||||||
val statistics: StateFlow<Statistics?>
|
val statistics: StateFlow<Statistics?>
|
||||||
get() = _statistics
|
get() = _statistics
|
||||||
|
|
||||||
private val _pendingPlaylistNaming = MutableEvent<PendingName.Args?>()
|
private val _pendingNewPlaylist = MutableEvent<PendingName.Args?>()
|
||||||
val pendingPlaylistNaming: Event<PendingName.Args?> = _pendingPlaylistNaming
|
val pendingNewPlaylist: Event<PendingName.Args?> = _pendingNewPlaylist
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addUpdateListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
|
@ -84,16 +86,37 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos
|
||||||
musicRepository.requestIndex(false)
|
musicRepository.requestIndex(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new generic playlist. This will automatically generate a playlist name and then
|
||||||
|
* prompt the user to edit the name before the creation finished.
|
||||||
|
*
|
||||||
|
* @param context The [Context] required to generate the playlist name.
|
||||||
|
* @param songs The [Song]s to be contained in the new playlist.
|
||||||
|
*/
|
||||||
|
fun createPlaylist(context: Context, songs: List<Song> = listOf()) {
|
||||||
|
val userLibrary = musicRepository.userLibrary ?: return
|
||||||
|
var i = 1
|
||||||
|
while (true) {
|
||||||
|
val possibleName = context.getString(R.string.fmt_def_playlist, i)
|
||||||
|
if (userLibrary.playlists.none { it.name.resolve(context) == possibleName }) {
|
||||||
|
createPlaylist(possibleName, songs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
++i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new generic playlist. This will prompt the user to edit the name before the creation
|
* Create a new generic playlist. This will prompt the user to edit the name before the creation
|
||||||
* finishes.
|
* finishes.
|
||||||
*
|
*
|
||||||
* @param name The preferred name of the new playlist.
|
* @param name The preferred name of the new playlist.
|
||||||
|
* @param songs The [Song]s to be contained in the new playlist.
|
||||||
*/
|
*/
|
||||||
fun createPlaylist(name: String, songs: List<Song> = listOf()) {
|
fun createPlaylist(name: String, songs: List<Song> = listOf()) {
|
||||||
// TODO: Default to something like "Playlist 1", "Playlist 2", etc.
|
// TODO: Default to something like "Playlist 1", "Playlist 2", etc.
|
||||||
// TODO: Attempt to unify playlist creation flow with dialog model
|
// TODO: Attempt to unify playlist creation flow with dialog model
|
||||||
_pendingPlaylistNaming.put(PendingName.Args(name, songs.map { it.uid }))
|
_pendingNewPlaylist.put(PendingName.Args(name, songs.map { it.uid }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -18,8 +18,6 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.device
|
package org.oxycblt.auxio.music.device
|
||||||
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Sort
|
import org.oxycblt.auxio.list.Sort
|
||||||
|
@ -35,6 +33,7 @@ import org.oxycblt.auxio.music.metadata.parseMultiValue
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
import org.oxycblt.auxio.util.toUuidOrNull
|
import org.oxycblt.auxio.util.toUuidOrNull
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
import org.oxycblt.auxio.util.update
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Library-backed implementation of [Song].
|
* Library-backed implementation of [Song].
|
||||||
|
@ -47,7 +46,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
|
||||||
override val uid =
|
override val uid =
|
||||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||||
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) }
|
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) }
|
||||||
?: createHashedUid(MusicMode.SONGS) {
|
?: Music.UID.auxio(MusicMode.SONGS) {
|
||||||
// Song UIDs are based on the raw data without parsing so that they remain
|
// Song UIDs are based on the raw data without parsing so that they remain
|
||||||
// consistent across music setting changes. Parents are not held up to the
|
// consistent across music setting changes. Parents are not held up to the
|
||||||
// same standard since grouping is already inherently linked to settings.
|
// same standard since grouping is already inherently linked to settings.
|
||||||
|
@ -231,7 +230,7 @@ class AlbumImpl(
|
||||||
override val uid =
|
override val uid =
|
||||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||||
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) }
|
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) }
|
||||||
?: createHashedUid(MusicMode.ALBUMS) {
|
?: Music.UID.auxio(MusicMode.ALBUMS) {
|
||||||
// Hash based on only names despite the presence of a date to increase stability.
|
// Hash based on only names despite the presence of a date to increase stability.
|
||||||
// I don't know if there is any situation where an artist will have two albums with
|
// I don't know if there is any situation where an artist will have two albums with
|
||||||
// the exact same name, but if there is, I would love to know.
|
// the exact same name, but if there is, I would love to know.
|
||||||
|
@ -327,7 +326,7 @@ class ArtistImpl(
|
||||||
override val uid =
|
override val uid =
|
||||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||||
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
|
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
|
||||||
?: createHashedUid(MusicMode.ARTISTS) { update(rawArtist.name) }
|
?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) }
|
||||||
override val name =
|
override val name =
|
||||||
rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) }
|
rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) }
|
||||||
?: Name.Unknown(R.string.def_artist)
|
?: Name.Unknown(R.string.def_artist)
|
||||||
|
@ -411,7 +410,7 @@ class GenreImpl(
|
||||||
musicSettings: MusicSettings,
|
musicSettings: MusicSettings,
|
||||||
override val songs: List<SongImpl>
|
override val songs: List<SongImpl>
|
||||||
) : Genre {
|
) : Genre {
|
||||||
override val uid = createHashedUid(MusicMode.GENRES) { update(rawGenre.name) }
|
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
|
||||||
override val name =
|
override val name =
|
||||||
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
|
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
|
||||||
?: Name.Unknown(R.string.def_genre)
|
?: Name.Unknown(R.string.def_genre)
|
||||||
|
@ -467,98 +466,3 @@ class GenreImpl(
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a [Music.UID] derived from the hash of objective music metadata.
|
|
||||||
*
|
|
||||||
* @param mode The analogous [MusicMode] of the item that created this [UID].
|
|
||||||
* @param updates Block to update the [MessageDigest] hash with the metadata of the item. Make sure
|
|
||||||
* the metadata hashed semantically aligns with the format specification.
|
|
||||||
* @return A new [Music.UID] of Auxio format whose [UUID] was derived from the SHA-256 hash of the
|
|
||||||
* metadata given.
|
|
||||||
*/
|
|
||||||
@VisibleForTesting
|
|
||||||
fun createHashedUid(mode: MusicMode, updates: MessageDigest.() -> Unit): Music.UID {
|
|
||||||
val digest =
|
|
||||||
MessageDigest.getInstance("SHA-256").run {
|
|
||||||
updates()
|
|
||||||
digest()
|
|
||||||
}
|
|
||||||
// Convert the digest to a UUID. This does cleave off some of the hash, but this
|
|
||||||
// is considered okay.
|
|
||||||
val uuid =
|
|
||||||
UUID(
|
|
||||||
digest[0]
|
|
||||||
.toLong()
|
|
||||||
.shl(56)
|
|
||||||
.or(digest[1].toLong().and(0xFF).shl(48))
|
|
||||||
.or(digest[2].toLong().and(0xFF).shl(40))
|
|
||||||
.or(digest[3].toLong().and(0xFF).shl(32))
|
|
||||||
.or(digest[4].toLong().and(0xFF).shl(24))
|
|
||||||
.or(digest[5].toLong().and(0xFF).shl(16))
|
|
||||||
.or(digest[6].toLong().and(0xFF).shl(8))
|
|
||||||
.or(digest[7].toLong().and(0xFF)),
|
|
||||||
digest[8]
|
|
||||||
.toLong()
|
|
||||||
.shl(56)
|
|
||||||
.or(digest[9].toLong().and(0xFF).shl(48))
|
|
||||||
.or(digest[10].toLong().and(0xFF).shl(40))
|
|
||||||
.or(digest[11].toLong().and(0xFF).shl(32))
|
|
||||||
.or(digest[12].toLong().and(0xFF).shl(24))
|
|
||||||
.or(digest[13].toLong().and(0xFF).shl(16))
|
|
||||||
.or(digest[14].toLong().and(0xFF).shl(8))
|
|
||||||
.or(digest[15].toLong().and(0xFF)))
|
|
||||||
return Music.UID.auxio(mode, uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a [MessageDigest] with a lowercase [String].
|
|
||||||
*
|
|
||||||
* @param string The [String] to hash. If null, it will not be hashed.
|
|
||||||
*/
|
|
||||||
@VisibleForTesting
|
|
||||||
fun MessageDigest.update(string: String?) {
|
|
||||||
if (string != null) {
|
|
||||||
update(string.lowercase().toByteArray())
|
|
||||||
} else {
|
|
||||||
update(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a [MessageDigest] with the string representation of a [Date].
|
|
||||||
*
|
|
||||||
* @param date The [Date] to hash. If null, nothing will be done.
|
|
||||||
*/
|
|
||||||
@VisibleForTesting
|
|
||||||
fun MessageDigest.update(date: Date?) {
|
|
||||||
if (date != null) {
|
|
||||||
update(date.toString().toByteArray())
|
|
||||||
} else {
|
|
||||||
update(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a [MessageDigest] with the lowercase versions of all of the input [String]s.
|
|
||||||
*
|
|
||||||
* @param strings The [String]s to hash. If a [String] is null, it will not be hashed.
|
|
||||||
*/
|
|
||||||
@VisibleForTesting
|
|
||||||
fun MessageDigest.update(strings: List<String?>) {
|
|
||||||
strings.forEach(::update)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a [MessageDigest] with the little-endian bytes of a [Int].
|
|
||||||
*
|
|
||||||
* @param n The [Int] to write. If null, nothing will be done.
|
|
||||||
*/
|
|
||||||
@VisibleForTesting
|
|
||||||
fun MessageDigest.update(n: Int?) {
|
|
||||||
if (n != null) {
|
|
||||||
update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte()))
|
|
||||||
} else {
|
|
||||||
update(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 Auxio Project
|
* Copyright (c) 2023 Auxio Project
|
||||||
* PlaylistNamingDialog.kt is part of Auxio.
|
* NewPlaylistDialog.kt is part of Auxio.
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -27,7 +27,7 @@ import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
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.DialogPlaylistNamingBinding
|
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
|
||||||
|
@ -37,13 +37,13 @@ import org.oxycblt.auxio.util.collectImmediately
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class PlaylistNamingDialog : ViewBindingDialogFragment<DialogPlaylistNamingBinding>() {
|
class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>() {
|
||||||
// activityViewModels is intentional here as the ViewModel will do work that we
|
// activityViewModels is intentional here as the ViewModel will do work that we
|
||||||
// do not want to cancel after this dialog closes.
|
// do not want to cancel after this dialog closes.
|
||||||
private val dialogModel: PlaylistDialogViewModel by activityViewModels()
|
private val dialogModel: PlaylistDialogViewModel by activityViewModels()
|
||||||
// Information about what playlist to name for is initially within the navigation arguments
|
// Information about what playlist to name for is initially within the navigation arguments
|
||||||
// as UIDs, as that is the only safe way to parcel playlist information.
|
// as UIDs, as that is the only safe way to parcel playlist information.
|
||||||
private val args: PlaylistNamingDialogArgs by navArgs()
|
private val args: NewPlaylistDialogArgs by navArgs()
|
||||||
private var initializedInput = false
|
private var initializedInput = false
|
||||||
|
|
||||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||||
|
@ -54,12 +54,9 @@ class PlaylistNamingDialog : ViewBindingDialogFragment<DialogPlaylistNamingBindi
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||||
DialogPlaylistNamingBinding.inflate(inflater)
|
DialogPlaylistNameBinding.inflate(inflater)
|
||||||
|
|
||||||
override fun onBindingCreated(
|
override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) {
|
||||||
binding: DialogPlaylistNamingBinding,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
) {
|
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
binding.playlistName.addTextChangedListener {
|
binding.playlistName.addTextChangedListener {
|
||||||
|
@ -82,6 +79,6 @@ class PlaylistNamingDialog : ViewBindingDialogFragment<DialogPlaylistNamingBindi
|
||||||
}
|
}
|
||||||
// Disable the OK button if the name is invalid (empty or whitespace)
|
// Disable the OK button if the name is invalid (empty or whitespace)
|
||||||
(dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled =
|
(dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled =
|
||||||
pendingName.name.isNotBlank()
|
pendingName.valid
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -31,6 +31,7 @@ import org.oxycblt.auxio.music.Song
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewModel] managing the state of the playlist editing dialogs.
|
* A [ViewModel] managing the state of the playlist editing dialogs.
|
||||||
|
*
|
||||||
* @author Alexander Capehart
|
* @author Alexander Capehart
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@ -44,15 +45,25 @@ class PlaylistDialogViewModel @Inject constructor(private val musicRepository: M
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (!changes.deviceLibrary) return
|
val pendingName = _currentPendingName.value ?: return
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
|
||||||
// Update the pending name to reflect new information in the music library.
|
val deviceLibrary = musicRepository.deviceLibrary
|
||||||
_currentPendingName.value =
|
val newSongs =
|
||||||
_currentPendingName.value?.let { pendingName ->
|
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||||
PendingName(
|
pendingName.songs.mapNotNull { deviceLibrary.findSong(it.uid) }
|
||||||
pendingName.name,
|
} else {
|
||||||
pendingName.songs.mapNotNull { deviceLibrary.findSong(it.uid) })
|
pendingName.songs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val userLibrary = musicRepository.userLibrary
|
||||||
|
val newValid =
|
||||||
|
if (changes.userLibrary && userLibrary != null) {
|
||||||
|
validateName(pendingName.name)
|
||||||
|
} else {
|
||||||
|
pendingName.valid
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPendingName.value = PendingName(pendingName.name, newSongs, newValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
@ -61,17 +72,22 @@ class PlaylistDialogViewModel @Inject constructor(private val musicRepository: M
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the current [PendingName] based on the given [PendingName.Args].
|
* Update the current [PendingName] based on the given [PendingName.Args].
|
||||||
|
*
|
||||||
* @param args The [PendingName.Args] to update with.
|
* @param args The [PendingName.Args] to update with.
|
||||||
*/
|
*/
|
||||||
fun setPendingName(args: PendingName.Args) {
|
fun setPendingName(args: PendingName.Args) {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||||
val name =
|
val name =
|
||||||
PendingName(args.preferredName, args.songUids.mapNotNull(deviceLibrary::findSong))
|
PendingName(
|
||||||
|
args.preferredName,
|
||||||
|
args.songUids.mapNotNull(deviceLibrary::findSong),
|
||||||
|
validateName(args.preferredName))
|
||||||
_currentPendingName.value = name
|
_currentPendingName.value = name
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the current [PendingName] based on new user input.
|
* Update the current [PendingName] based on new user input.
|
||||||
|
*
|
||||||
* @param name The new user-inputted name, directly from the UI.
|
* @param name The new user-inputted name, directly from the UI.
|
||||||
*/
|
*/
|
||||||
fun updatePendingName(name: String?) {
|
fun updatePendingName(name: String?) {
|
||||||
|
@ -79,25 +95,31 @@ class PlaylistDialogViewModel @Inject constructor(private val musicRepository: M
|
||||||
// music items.
|
// music items.
|
||||||
val normalized = (name ?: return).trim()
|
val normalized = (name ?: return).trim()
|
||||||
_currentPendingName.value =
|
_currentPendingName.value =
|
||||||
_currentPendingName.value?.run { PendingName(normalized, songs) }
|
_currentPendingName.value?.run { PendingName(normalized, songs, validateName(name)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Confirm the current [PendingName] operation and write it to the database. */
|
||||||
* Confirm the current [PendingName] operation and write it to the database.
|
|
||||||
*/
|
|
||||||
fun confirmPendingName() {
|
fun confirmPendingName() {
|
||||||
val pendingName = _currentPendingName.value ?: return
|
val pendingName = _currentPendingName.value ?: return
|
||||||
musicRepository.createPlaylist(pendingName.name, pendingName.songs)
|
musicRepository.createPlaylist(pendingName.name, pendingName.songs)
|
||||||
_currentPendingName.value = null
|
_currentPendingName.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun validateName(name: String) =
|
||||||
|
name.isNotBlank() && musicRepository.userLibrary?.findPlaylist(name) == null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a name operation
|
* Represents the current state of a name operation.
|
||||||
|
*
|
||||||
|
* @param name The name of the playlist.
|
||||||
|
* @param songs Any songs that will be in the playlist when added.
|
||||||
|
* @param valid Whether the current configuration is valid.
|
||||||
*/
|
*/
|
||||||
data class PendingName(val name: String, val songs: List<Song>) {
|
data class PendingName(val name: String, val songs: List<Song>, val valid: Boolean) {
|
||||||
/**
|
/**
|
||||||
* A [Parcelable] version of [PendingName], to be used as a dialog argument.
|
* A [Parcelable] version of [PendingName], to be used as a dialog argument.
|
||||||
|
*
|
||||||
* @param preferredName The name to be used initially by the dialog.
|
* @param preferredName The name to be used initially by the dialog.
|
||||||
* @param songUids The [Music.UID] of any pending [Song]s that will be put in the playlist.
|
* @param songUids The [Music.UID] of any pending [Song]s that will be put in the playlist.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -22,11 +22,12 @@ import java.util.*
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||||
import org.oxycblt.auxio.music.info.Name
|
import org.oxycblt.auxio.music.info.Name
|
||||||
|
import org.oxycblt.auxio.util.update
|
||||||
|
|
||||||
class PlaylistImpl
|
class PlaylistImpl
|
||||||
private constructor(
|
private constructor(
|
||||||
override val uid: Music.UID,
|
override val uid: Music.UID,
|
||||||
override val name: Name,
|
override val name: Name.Known,
|
||||||
override val songs: List<Song>
|
override val songs: List<Song>
|
||||||
) : Playlist {
|
) : Playlist {
|
||||||
override val durationMs = songs.sumOf { it.durationMs }
|
override val durationMs = songs.sumOf { it.durationMs }
|
||||||
|
@ -62,7 +63,7 @@ private constructor(
|
||||||
*/
|
*/
|
||||||
fun from(name: String, songs: List<Song>, musicSettings: MusicSettings) =
|
fun from(name: String, songs: List<Song>, musicSettings: MusicSettings) =
|
||||||
PlaylistImpl(
|
PlaylistImpl(
|
||||||
Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()),
|
Music.UID.auxio(MusicMode.PLAYLISTS) { update(name) },
|
||||||
Name.Known.from(name, null, musicSettings),
|
Name.Known.from(name, null, musicSettings),
|
||||||
songs)
|
songs)
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,14 @@ interface UserLibrary {
|
||||||
*/
|
*/
|
||||||
fun findPlaylist(uid: Music.UID): Playlist?
|
fun findPlaylist(uid: Music.UID): Playlist?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a playlist by it's [name]. Since all [Playlist] names must be unique, this will always
|
||||||
|
* return at most 1 value.
|
||||||
|
*
|
||||||
|
* @param name The name [String] to search for.
|
||||||
|
*/
|
||||||
|
fun findPlaylist(name: String): Playlist?
|
||||||
|
|
||||||
/** Constructs a [UserLibrary] implementation in an asynchronous manner. */
|
/** Constructs a [UserLibrary] implementation in an asynchronous manner. */
|
||||||
interface Factory {
|
interface Factory {
|
||||||
/**
|
/**
|
||||||
|
@ -104,6 +112,8 @@ private class UserLibraryImpl(
|
||||||
|
|
||||||
override fun findPlaylist(uid: Music.UID) = playlistMap[uid]
|
override fun findPlaylist(uid: Music.UID) = playlistMap[uid]
|
||||||
|
|
||||||
|
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override 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)
|
||||||
|
|
|
@ -18,9 +18,11 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.util
|
package org.oxycblt.auxio.util
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
|
import org.oxycblt.auxio.music.info.Date
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitizes a value that is unlikely to be null. On debug builds, this aliases to [requireNotNull],
|
* Sanitizes a value that is unlikely to be null. On debug builds, this aliases to [requireNotNull],
|
||||||
|
@ -89,3 +91,51 @@ fun String.toUuidOrNull(): UUID? =
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a [MessageDigest] with a lowercase [String].
|
||||||
|
*
|
||||||
|
* @param string The [String] to hash. If null, it will not be hashed.
|
||||||
|
*/
|
||||||
|
fun MessageDigest.update(string: String?) {
|
||||||
|
if (string != null) {
|
||||||
|
update(string.lowercase().toByteArray())
|
||||||
|
} else {
|
||||||
|
update(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a [MessageDigest] with the string representation of a [Date].
|
||||||
|
*
|
||||||
|
* @param date The [Date] to hash. If null, nothing will be done.
|
||||||
|
*/
|
||||||
|
fun MessageDigest.update(date: Date?) {
|
||||||
|
if (date != null) {
|
||||||
|
update(date.toString().toByteArray())
|
||||||
|
} else {
|
||||||
|
update(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a [MessageDigest] with the lowercase versions of all of the input [String]s.
|
||||||
|
*
|
||||||
|
* @param strings The [String]s to hash. If a [String] is null, it will not be hashed.
|
||||||
|
*/
|
||||||
|
fun MessageDigest.update(strings: List<String?>) {
|
||||||
|
strings.forEach(::update)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a [MessageDigest] with the little-endian bytes of a [Int].
|
||||||
|
*
|
||||||
|
* @param n The [Int] to write. If null, nothing will be done.
|
||||||
|
*/
|
||||||
|
fun MessageDigest.update(n: Int?) {
|
||||||
|
if (n != null) {
|
||||||
|
update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte()))
|
||||||
|
} else {
|
||||||
|
update(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -18,8 +18,8 @@
|
||||||
android:id="@+id/action_show_details"
|
android:id="@+id/action_show_details"
|
||||||
app:destination="@id/song_detail_dialog" />
|
app:destination="@id/song_detail_dialog" />
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_name_playlist"
|
android:id="@+id/action_new_playlist"
|
||||||
app:destination="@id/playlist_naming_dialog" />
|
app:destination="@id/new_playlist_dialog" />
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_pick_navigation_artist"
|
android:id="@+id/action_pick_navigation_artist"
|
||||||
app:destination="@id/artist_navigation_picker_dialog" />
|
app:destination="@id/artist_navigation_picker_dialog" />
|
||||||
|
@ -42,10 +42,10 @@
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog
|
<dialog
|
||||||
android:id="@+id/playlist_naming_dialog"
|
android:id="@+id/new_playlist_dialog"
|
||||||
android:name="org.oxycblt.auxio.music.dialog.PlaylistNamingDialog"
|
android:name="org.oxycblt.auxio.music.dialog.NewPlaylistDialog"
|
||||||
android:label="playlist_naming_dialog"
|
android:label="new_playlist_dialog"
|
||||||
tools:layout="@layout/dialog_song_detail">
|
tools:layout="@layout/dialog_playlist_name">
|
||||||
<argument
|
<argument
|
||||||
android:name="pendingName"
|
android:name="pendingName"
|
||||||
app:argType="org.oxycblt.auxio.music.dialog.PendingName$Args" />
|
app:argType="org.oxycblt.auxio.music.dialog.PendingName$Args" />
|
||||||
|
|
|
@ -376,6 +376,8 @@
|
||||||
|
|
||||||
<!-- As in "Disc 1", "Disc 2", etc. in a set -->
|
<!-- As in "Disc 1", "Disc 2", etc. in a set -->
|
||||||
<string name="fmt_disc_no">Disc %d</string>
|
<string name="fmt_disc_no">Disc %d</string>
|
||||||
|
<!-- As in "Playlist 1", "Playlist 2", etc. -->
|
||||||
|
<string name="fmt_def_playlist">Playlist %d</string>
|
||||||
|
|
||||||
<!-- Use your native country's abbreviation for decibel units. -->
|
<!-- Use your native country's abbreviation for decibel units. -->
|
||||||
<string name="fmt_db_pos">+%.1f dB</string>
|
<string name="fmt_db_pos">+%.1f dB</string>
|
||||||
|
|
Loading…
Reference in a new issue