diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 148f665b1..c06b4536b 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -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() } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index c8d9083d4..a7ae52234 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -138,7 +138,6 @@ class PlaylistDetailFragment : } override fun onPlay() { - // TODO: Handle playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value)) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 9f2514c71..c7d6f9cac 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -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()) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 9c3d37a7e..684dd3e00 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 6707a6694..259fb5816 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -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 get() = _statistics - private val _pendingPlaylistNaming = MutableEvent() - val pendingPlaylistNaming: Event = _pendingPlaylistNaming + private val _pendingNewPlaylist = MutableEvent() + val pendingNewPlaylist: Event = _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 = 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 = 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 })) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index b0ddb0ca7..e239f97c2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -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 ) : 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) { - 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) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistNamingDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt similarity index 86% rename from app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistNamingDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt index 6afaf866e..944db7fc2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dialog/PlaylistNamingDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dialog/NewPlaylistDialog.kt @@ -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() { +class NewPlaylistDialog : ViewBindingDialogFragment() { // 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 - 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) { +data class PendingName(val name: String, val songs: List, 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. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt index cbab2a634..8cf7fe213 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -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 ) : Playlist { override val durationMs = songs.sumOf { it.durationMs } @@ -62,7 +63,7 @@ private constructor( */ fun from(name: String, songs: List, musicSettings: MusicSettings) = PlaylistImpl( - Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()), + Music.UID.auxio(MusicMode.PLAYLISTS) { update(name) }, Name.Known.from(name, null, musicSettings), songs) diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index 34717d77e..e4ae2ab41 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -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) { val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) diff --git a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt index 1aab1edd1..51835d68c 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -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) { + 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) + } +} diff --git a/app/src/main/res/layout/dialog_playlist_naming.xml b/app/src/main/res/layout/dialog_playlist_name.xml similarity index 100% rename from app/src/main/res/layout/dialog_playlist_naming.xml rename to app/src/main/res/layout/dialog_playlist_name.xml diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 1be49a828..244d189ca 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -18,8 +18,8 @@ android:id="@+id/action_show_details" app:destination="@id/song_detail_dialog" /> + android:id="@+id/action_new_playlist" + app:destination="@id/new_playlist_dialog" /> @@ -42,10 +42,10 @@ + 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"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07d611778..070b3a927 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -376,6 +376,8 @@ Disc %d + + Playlist %d +%.1f dB