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:
Alexander Capehart 2023-05-12 13:41:39 -06:00
parent e01ea25d0b
commit 763061c352
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
14 changed files with 188 additions and 145 deletions

View file

@ -135,7 +135,7 @@ class MainFragment :
collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
collect(musicModel.pendingPlaylistNaming.flow, ::handlePlaylistNaming)
collect(musicModel.pendingNewPlaylist.flow, ::handlePlaylistNaming)
collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
@ -306,8 +306,8 @@ class MainFragment :
private fun handlePlaylistNaming(args: PendingName.Args?) {
if (args != null) {
findNavController().navigateSafe(MainFragmentDirections.actionNamePlaylist(args))
musicModel.pendingPlaylistNaming.consume()
findNavController().navigateSafe(MainFragmentDirections.actionNewPlaylist(args))
musicModel.pendingNewPlaylist.consume()
}
}

View file

@ -138,7 +138,6 @@ class PlaylistDetailFragment :
}
override fun onPlay() {
// TODO: Handle
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}

View file

@ -321,7 +321,7 @@ class HomeFragment :
}
} else {
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
musicModel.createPlaylist("New playlist")
musicModel.createPlaylist(requireContext())
}
}
}

View file

@ -22,6 +22,7 @@ import android.content.Context
import android.net.Uri
import android.os.Parcelable
import androidx.room.TypeConverter
import java.security.MessageDigest
import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel
@ -118,13 +119,47 @@ sealed interface Music : Item {
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 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].
*/
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

View file

@ -18,11 +18,13 @@
package org.oxycblt.auxio.music
import android.content.Context
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.dialog.PendingName
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
@ -45,8 +47,8 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos
val statistics: StateFlow<Statistics?>
get() = _statistics
private val _pendingPlaylistNaming = MutableEvent<PendingName.Args?>()
val pendingPlaylistNaming: Event<PendingName.Args?> = _pendingPlaylistNaming
private val _pendingNewPlaylist = MutableEvent<PendingName.Args?>()
val pendingNewPlaylist: Event<PendingName.Args?> = _pendingNewPlaylist
init {
musicRepository.addUpdateListener(this)
@ -84,16 +86,37 @@ class MusicViewModel @Inject constructor(private val musicRepository: MusicRepos
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
* finishes.
*
* @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()) {
// TODO: Default to something like "Playlist 1", "Playlist 2", etc.
// 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 }))
}
/**

View file

@ -18,8 +18,6 @@
package org.oxycblt.auxio.music.device
import androidx.annotation.VisibleForTesting
import java.security.MessageDigest
import java.util.*
import org.oxycblt.auxio.R
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.toUuidOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.auxio.util.update
/**
* Library-backed implementation of [Song].
@ -47,7 +46,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
override val 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) }
?: createHashedUid(MusicMode.SONGS) {
?: Music.UID.auxio(MusicMode.SONGS) {
// 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
// same standard since grouping is already inherently linked to settings.
@ -231,7 +230,7 @@ class AlbumImpl(
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
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.
// 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.
@ -327,7 +326,7 @@ class ArtistImpl(
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
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 =
rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) }
?: Name.Unknown(R.string.def_artist)
@ -411,7 +410,7 @@ class GenreImpl(
musicSettings: MusicSettings,
override val songs: List<SongImpl>
) : Genre {
override val uid = createHashedUid(MusicMode.GENRES) { update(rawGenre.name) }
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
override val name =
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
?: Name.Unknown(R.string.def_genre)
@ -467,98 +466,3 @@ class GenreImpl(
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)
}
}

View file

@ -1,6 +1,6 @@
/*
* 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
* 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 dagger.hilt.android.AndroidEntryPoint
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.util.collectImmediately
@ -37,13 +37,13 @@ import org.oxycblt.auxio.util.collectImmediately
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class PlaylistNamingDialog : ViewBindingDialogFragment<DialogPlaylistNamingBinding>() {
class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>() {
// activityViewModels is intentional here as the ViewModel will do work that we
// do not want to cancel after this dialog closes.
private val dialogModel: PlaylistDialogViewModel by activityViewModels()
// 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.
private val args: PlaylistNamingDialogArgs by navArgs()
private val args: NewPlaylistDialogArgs by navArgs()
private var initializedInput = false
override fun onConfigDialog(builder: AlertDialog.Builder) {
@ -54,12 +54,9 @@ class PlaylistNamingDialog : ViewBindingDialogFragment<DialogPlaylistNamingBindi
}
override fun onCreateBinding(inflater: LayoutInflater) =
DialogPlaylistNamingBinding.inflate(inflater)
DialogPlaylistNameBinding.inflate(inflater)
override fun onBindingCreated(
binding: DialogPlaylistNamingBinding,
savedInstanceState: Bundle?
) {
override fun onBindingCreated(binding: DialogPlaylistNameBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
binding.playlistName.addTextChangedListener {
@ -82,6 +79,6 @@ class PlaylistNamingDialog : ViewBindingDialogFragment<DialogPlaylistNamingBindi
}
// Disable the OK button if the name is invalid (empty or whitespace)
(dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled =
pendingName.name.isNotBlank()
pendingName.valid
}
}

View file

@ -31,6 +31,7 @@ import org.oxycblt.auxio.music.Song
/**
* A [ViewModel] managing the state of the playlist editing dialogs.
*
* @author Alexander Capehart
*/
@HiltViewModel
@ -44,15 +45,25 @@ class PlaylistDialogViewModel @Inject constructor(private val musicRepository: M
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return
val deviceLibrary = musicRepository.deviceLibrary ?: return
// Update the pending name to reflect new information in the music library.
_currentPendingName.value =
_currentPendingName.value?.let { pendingName ->
PendingName(
pendingName.name,
pendingName.songs.mapNotNull { deviceLibrary.findSong(it.uid) })
val pendingName = _currentPendingName.value ?: return
val deviceLibrary = musicRepository.deviceLibrary
val newSongs =
if (changes.deviceLibrary && deviceLibrary != null) {
pendingName.songs.mapNotNull { deviceLibrary.findSong(it.uid) }
} else {
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() {
@ -61,17 +72,22 @@ class PlaylistDialogViewModel @Inject constructor(private val musicRepository: M
/**
* Update the current [PendingName] based on the given [PendingName.Args].
*
* @param args The [PendingName.Args] to update with.
*/
fun setPendingName(args: PendingName.Args) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
val name =
PendingName(args.preferredName, args.songUids.mapNotNull(deviceLibrary::findSong))
PendingName(
args.preferredName,
args.songUids.mapNotNull(deviceLibrary::findSong),
validateName(args.preferredName))
_currentPendingName.value = name
}
/**
* Update the current [PendingName] based on new user input.
*
* @param name The new user-inputted name, directly from the UI.
*/
fun updatePendingName(name: String?) {
@ -79,25 +95,31 @@ class PlaylistDialogViewModel @Inject constructor(private val musicRepository: M
// music items.
val normalized = (name ?: return).trim()
_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() {
val pendingName = _currentPendingName.value ?: return
musicRepository.createPlaylist(pendingName.name, pendingName.songs)
_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.
*
* @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.
*/

View file

@ -22,11 +22,12 @@ import java.util.*
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.util.update
class PlaylistImpl
private constructor(
override val uid: Music.UID,
override val name: Name,
override val name: Name.Known,
override val songs: List<Song>
) : Playlist {
override val durationMs = songs.sumOf { it.durationMs }
@ -62,7 +63,7 @@ private constructor(
*/
fun from(name: String, songs: List<Song>, musicSettings: MusicSettings) =
PlaylistImpl(
Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()),
Music.UID.auxio(MusicMode.PLAYLISTS) { update(name) },
Name.Known.from(name, null, musicSettings),
songs)

View file

@ -44,6 +44,14 @@ interface UserLibrary {
*/
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. */
interface Factory {
/**
@ -104,6 +112,8 @@ private class UserLibraryImpl(
override fun findPlaylist(uid: Music.UID) = playlistMap[uid]
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
@Synchronized
override fun createPlaylist(name: String, songs: List<Song>) {
val playlistImpl = PlaylistImpl.from(name, songs, musicSettings)

View file

@ -18,9 +18,11 @@
package org.oxycblt.auxio.util
import java.security.MessageDigest
import java.util.UUID
import kotlin.reflect.KClass
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],
@ -89,3 +91,51 @@ fun String.toUuidOrNull(): UUID? =
} catch (e: IllegalArgumentException) {
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)
}
}

View file

@ -18,8 +18,8 @@
android:id="@+id/action_show_details"
app:destination="@id/song_detail_dialog" />
<action
android:id="@+id/action_name_playlist"
app:destination="@id/playlist_naming_dialog" />
android:id="@+id/action_new_playlist"
app:destination="@id/new_playlist_dialog" />
<action
android:id="@+id/action_pick_navigation_artist"
app:destination="@id/artist_navigation_picker_dialog" />
@ -42,10 +42,10 @@
</dialog>
<dialog
android:id="@+id/playlist_naming_dialog"
android:name="org.oxycblt.auxio.music.dialog.PlaylistNamingDialog"
android:label="playlist_naming_dialog"
tools:layout="@layout/dialog_song_detail">
android:id="@+id/new_playlist_dialog"
android:name="org.oxycblt.auxio.music.dialog.NewPlaylistDialog"
android:label="new_playlist_dialog"
tools:layout="@layout/dialog_playlist_name">
<argument
android:name="pendingName"
app:argType="org.oxycblt.auxio.music.dialog.PendingName$Args" />

View file

@ -376,6 +376,8 @@
<!-- As in "Disc 1", "Disc 2", etc. in a set -->
<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. -->
<string name="fmt_db_pos">+%.1f dB</string>