music: add framework for playlist mutation
Add the boilerplate for basic playlist creation/addition. No integration in UI yet.
This commit is contained in:
parent
70e824c4e7
commit
9988a1b76b
14 changed files with 142 additions and 107 deletions
|
@ -24,9 +24,6 @@ deletion
|
|||
#### What's Changed
|
||||
- "Ignore articles when sorting" is now "Intelligent sorting"
|
||||
|
||||
#### What's Changed
|
||||
- "Ignore articles when sorting" is now "Intelligent sorting"
|
||||
|
||||
## 3.0.3
|
||||
|
||||
#### What's New
|
||||
|
|
|
@ -53,7 +53,7 @@ class DetailViewModel
|
|||
@Inject
|
||||
constructor(
|
||||
private val musicRepository: MusicRepository,
|
||||
private val audioInfoProvider: AudioInfo.Provider,
|
||||
private val audioInfoFactory: AudioInfo.Factory,
|
||||
private val musicSettings: MusicSettings,
|
||||
private val playbackSettings: PlaybackSettings
|
||||
) : ViewModel(), MusicRepository.UpdateListener {
|
||||
|
@ -308,7 +308,7 @@ constructor(
|
|||
_songAudioInfo.value = null
|
||||
currentSongJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val info = audioInfoProvider.extract(song)
|
||||
val info = audioInfoFactory.extract(song)
|
||||
yield()
|
||||
_songAudioInfo.value = info
|
||||
}
|
||||
|
|
|
@ -172,8 +172,8 @@ constructor(
|
|||
private val cacheRepository: CacheRepository,
|
||||
private val mediaStoreExtractor: MediaStoreExtractor,
|
||||
private val tagExtractor: TagExtractor,
|
||||
private val deviceLibraryProvider: DeviceLibrary.Provider,
|
||||
private val userLibraryProvider: UserLibrary.Provider
|
||||
private val deviceLibraryFactory: DeviceLibrary.Factory,
|
||||
private val userLibraryFactory: UserLibrary.Factory
|
||||
) : MusicRepository {
|
||||
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
||||
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
|
||||
|
@ -314,9 +314,9 @@ constructor(
|
|||
val deviceLibraryChannel = Channel<DeviceLibrary>()
|
||||
val deviceLibraryJob =
|
||||
worker.scope.async(Dispatchers.Main) {
|
||||
deviceLibraryProvider.create(rawSongs).also { deviceLibraryChannel.send(it) }
|
||||
deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) }
|
||||
}
|
||||
val userLibraryJob = worker.scope.async { userLibraryProvider.read(deviceLibraryChannel) }
|
||||
val userLibraryJob = worker.scope.async { userLibraryFactory.read(deviceLibraryChannel) }
|
||||
if (cache == null || cache.invalidated) {
|
||||
cacheRepository.writeCache(rawSongs)
|
||||
}
|
||||
|
|
|
@ -89,7 +89,7 @@ interface DeviceLibrary {
|
|||
fun findGenre(uid: Music.UID): Genre?
|
||||
|
||||
/** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */
|
||||
interface Provider {
|
||||
interface Factory {
|
||||
/**
|
||||
* Create a new [DeviceLibrary].
|
||||
*
|
||||
|
@ -110,8 +110,8 @@ interface DeviceLibrary {
|
|||
}
|
||||
}
|
||||
|
||||
class DeviceLibraryProviderImpl @Inject constructor(private val musicSettings: MusicSettings) :
|
||||
DeviceLibrary.Provider {
|
||||
class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) :
|
||||
DeviceLibrary.Factory {
|
||||
override suspend fun create(rawSongs: List<RawSong>): DeviceLibrary =
|
||||
DeviceLibraryImpl(rawSongs, musicSettings)
|
||||
}
|
||||
|
|
|
@ -26,6 +26,5 @@ import dagger.hilt.components.SingletonComponent
|
|||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DeviceModule {
|
||||
@Binds
|
||||
fun deviceLibraryProvider(providerImpl: DeviceLibraryProviderImpl): DeviceLibrary.Provider
|
||||
@Binds fun deviceLibraryProvider(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
|
||||
}
|
||||
|
|
|
@ -342,8 +342,8 @@ class ArtistImpl(
|
|||
override val durationMs: Long?
|
||||
override val isCollaborator: Boolean
|
||||
|
||||
// Note: Append song contents to MusicParent equality so that Groups with
|
||||
// the same UID but different contents are not equal.
|
||||
// Note: Append song contents to MusicParent equality so that artists with
|
||||
// the same UID but different songs are not equal.
|
||||
override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
|
||||
override fun equals(other: Any?) =
|
||||
other is ArtistImpl && uid == other.uid && songs == other.songs
|
||||
|
|
|
@ -186,13 +186,13 @@ class RawGenre(
|
|||
/** @see Music.rawName */
|
||||
val name: String? = null
|
||||
) {
|
||||
// Only group by the lowercase genre name. This allows Genre grouping to be
|
||||
// case-insensitive, which may be helpful in some libraries with different ways of
|
||||
// formatting genres.
|
||||
|
||||
// Cache the hashCode for HashMap efficiency.
|
||||
private val hashCode = name?.lowercase().hashCode()
|
||||
|
||||
// Only group by the lowercase genre name. This allows Genre grouping to be
|
||||
// case-insensitive, which may be helpful in some libraries with different ways of
|
||||
// formatting genres.
|
||||
override fun hashCode() = hashCode
|
||||
|
||||
override fun equals(other: Any?) =
|
||||
|
|
|
@ -178,8 +178,8 @@ private abstract class BaseMediaStoreExtractor(
|
|||
|
||||
while (cursor.moveToNext()) {
|
||||
// Assume that a song can't inhabit multiple genre entries, as I
|
||||
// doubt
|
||||
// MediaStore is actually aware that songs can have multiple genres.
|
||||
// doubt MediaStore is actually aware that songs can have multiple
|
||||
// genres.
|
||||
genreNamesMap[cursor.getLong(songIdIndex)] = name
|
||||
}
|
||||
}
|
||||
|
@ -311,9 +311,8 @@ private abstract class BaseMediaStoreExtractor(
|
|||
rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from)
|
||||
// A non-existent album name should theoretically be the name of the folder it contained
|
||||
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it
|
||||
// the
|
||||
// file is not actually in the root internal storage directory. We can't do anything to
|
||||
// fix this, really.
|
||||
// the file is not actually in the root internal storage directory. We can't do
|
||||
// anything to fix this, really.
|
||||
rawSong.albumName = cursor.getString(albumIndex)
|
||||
// Android does not make a non-existent artist tag null, it instead fills it in
|
||||
// as <unknown>, which makes absolutely no sense given how other columns default
|
||||
|
@ -356,9 +355,6 @@ private abstract class BaseMediaStoreExtractor(
|
|||
// Note: The separation between version-specific backends may not be the cleanest. To preserve
|
||||
// speed, we only want to add redundancy on known issues, not with possible issues.
|
||||
|
||||
// Note: The separation between version-specific backends may not be the cleanest. To preserve
|
||||
// speed, we only want to add redundancy on known issues, not with possible issues.
|
||||
|
||||
private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
|
||||
BaseMediaStoreExtractor(context, musicSettings) {
|
||||
override val projection: Array<String>
|
||||
|
|
|
@ -43,7 +43,7 @@ data class AudioInfo(
|
|||
val resolvedMimeType: MimeType
|
||||
) {
|
||||
/** Implements the process of extracting [AudioInfo] from a given [Song]. */
|
||||
interface Provider {
|
||||
interface Factory {
|
||||
/**
|
||||
* Extract the [AudioInfo] of a given [Song].
|
||||
*
|
||||
|
@ -55,12 +55,12 @@ data class AudioInfo(
|
|||
}
|
||||
|
||||
/**
|
||||
* A framework-backed implementation of [AudioInfo.Provider].
|
||||
* A framework-backed implementation of [AudioInfo.Factory].
|
||||
*
|
||||
* @param context [Context] required to read audio files.
|
||||
*/
|
||||
class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||
AudioInfo.Provider {
|
||||
class AudioInfoFactoryImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||
AudioInfo.Factory {
|
||||
|
||||
override suspend fun extract(song: Song): AudioInfo {
|
||||
// While we would use ExoPlayer to extract this information, it doesn't support
|
||||
|
|
|
@ -26,7 +26,7 @@ import dagger.hilt.components.SingletonComponent
|
|||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface MetadataModule {
|
||||
@Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor
|
||||
@Binds fun tagWorkerFactory(taskFactory: TagWorkerImpl.Factory): TagWorker.Factory
|
||||
@Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider
|
||||
@Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor
|
||||
@Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory
|
||||
@Binds fun audioInfoProvider(factory: AudioInfoFactoryImpl): AudioInfo.Factory
|
||||
}
|
||||
|
|
|
@ -56,14 +56,26 @@ interface TagWorker {
|
|||
}
|
||||
}
|
||||
|
||||
class TagWorkerImpl
|
||||
private constructor(private val rawSong: RawSong, private val future: Future<TrackGroupArray>) :
|
||||
TagWorker {
|
||||
/**
|
||||
* Try to get a completed song from this [TagWorker], if it has finished processing.
|
||||
*
|
||||
* @return A [RawSong] instance if processing has completed, null otherwise.
|
||||
*/
|
||||
class TagWorkerFactoryImpl
|
||||
@Inject
|
||||
constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory {
|
||||
override fun create(rawSong: RawSong): TagWorker =
|
||||
// Note that we do not leverage future callbacks. This is because errors in the
|
||||
// (highly fallible) extraction process will not bubble up to Indexer when a
|
||||
// listener is used, instead crashing the app entirely.
|
||||
TagWorkerImpl(
|
||||
rawSong,
|
||||
MetadataRetriever.retrieveMetadata(
|
||||
mediaSourceFactory,
|
||||
MediaItem.fromUri(
|
||||
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())))
|
||||
}
|
||||
|
||||
private class TagWorkerImpl(
|
||||
private val rawSong: RawSong,
|
||||
private val future: Future<TrackGroupArray>
|
||||
) : TagWorker {
|
||||
|
||||
override fun poll(): RawSong? {
|
||||
if (!future.isDone) {
|
||||
// Not done yet, nothing to do.
|
||||
|
@ -95,12 +107,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
|
|||
return rawSong
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete this instance's [RawSong] with ID3v2 Text Identification Frames.
|
||||
*
|
||||
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
|
||||
* values.
|
||||
*/
|
||||
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
|
||||
// Song
|
||||
textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
|
||||
|
@ -169,16 +175,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the ID3v2.3 timestamp specification into a [Date] from the given Text Identification
|
||||
* Frames.
|
||||
*
|
||||
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
|
||||
* values.
|
||||
* @return A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a
|
||||
* hour/minute value from TIME. No second value is included. The latter two fields may not be
|
||||
* included in they cannot be parsed. Will be null if a year value could not be parsed.
|
||||
*/
|
||||
private fun parseId3v23Date(textFrames: Map<String, List<String>>): Date? {
|
||||
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
||||
// is present.
|
||||
|
@ -212,11 +208,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete this instance's [RawSong] with Vorbis comments.
|
||||
*
|
||||
* @param comments A mapping between vorbis comment names and one or more vorbis comment values.
|
||||
*/
|
||||
private fun populateWithVorbis(comments: Map<String, List<String>>) {
|
||||
// Song
|
||||
comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
|
||||
|
@ -277,21 +268,6 @@ private constructor(private val rawSong: RawSong, private val future: Future<Tra
|
|||
}
|
||||
}
|
||||
|
||||
class Factory @Inject constructor(private val mediaSourceFactory: MediaSource.Factory) :
|
||||
TagWorker.Factory {
|
||||
override fun create(rawSong: RawSong) =
|
||||
// Note that we do not leverage future callbacks. This is because errors in the
|
||||
// (highly fallible) extraction process will not bubble up to Indexer when a
|
||||
// listener is used, instead crashing the app entirely.
|
||||
TagWorkerImpl(
|
||||
rawSong,
|
||||
MetadataRetriever.retrieveMetadata(
|
||||
mediaSourceFactory,
|
||||
MediaItem.fromUri(
|
||||
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }
|
||||
.toAudioUri())))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
|
||||
val COMPILATION_RELEASE_TYPES = listOf("compilation")
|
||||
|
|
|
@ -27,29 +27,60 @@ class PlaylistImpl
|
|||
private constructor(
|
||||
override val uid: Music.UID,
|
||||
override val rawName: String,
|
||||
override val songs: List<Song>,
|
||||
musicSettings: MusicSettings
|
||||
override val sortName: SortName,
|
||||
override val songs: List<Song>
|
||||
) : Playlist {
|
||||
constructor(
|
||||
name: String,
|
||||
songs: List<Song>,
|
||||
musicSettings: MusicSettings
|
||||
) : this(Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()), name, songs, musicSettings)
|
||||
|
||||
constructor(
|
||||
rawPlaylist: RawPlaylist,
|
||||
deviceLibrary: DeviceLibrary,
|
||||
musicSettings: MusicSettings
|
||||
) : this(
|
||||
rawPlaylist.playlistInfo.playlistUid,
|
||||
rawPlaylist.playlistInfo.name,
|
||||
rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) },
|
||||
musicSettings)
|
||||
|
||||
override fun resolveName(context: Context) = rawName
|
||||
override val rawSortName = null
|
||||
override val sortName = SortName(rawName, musicSettings)
|
||||
override val durationMs = songs.sumOf { it.durationMs }
|
||||
override val albums =
|
||||
songs.groupBy { it.album }.entries.sortedByDescending { it.value.size }.map { it.key }
|
||||
|
||||
/**
|
||||
* Clone the data in this instance to a new [PlaylistImpl] with the given [Song]s.
|
||||
*
|
||||
* @param songs The new [Song]s to use.
|
||||
*/
|
||||
fun edit(songs: List<Song>) = PlaylistImpl(uid, rawName, sortName, songs)
|
||||
|
||||
/**
|
||||
* Clone the data in this instance to a new [PlaylistImpl] with the given [edits].
|
||||
*
|
||||
* @param edits The edits to make to the [Song]s of the playlist.
|
||||
*/
|
||||
inline fun edit(edits: MutableList<Song>.() -> Unit) = edit(songs.toMutableList().apply(edits))
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance with a novel UID.
|
||||
*
|
||||
* @param name The name of the playlist.
|
||||
* @param songs The songs to initially populate the playlist with.
|
||||
* @param musicSettings [MusicSettings] required for name configuration.
|
||||
*/
|
||||
fun new(name: String, songs: List<Song>, musicSettings: MusicSettings) =
|
||||
PlaylistImpl(
|
||||
Music.UID.auxio(MusicMode.PLAYLISTS, UUID.randomUUID()),
|
||||
name,
|
||||
SortName(name, musicSettings),
|
||||
songs)
|
||||
|
||||
/**
|
||||
* Populate a new instance from a read [RawPlaylist].
|
||||
*
|
||||
* @param rawPlaylist The [RawPlaylist] to read from.
|
||||
* @param deviceLibrary The [DeviceLibrary] to initialize from.
|
||||
* @param musicSettings [MusicSettings] required for name configuration.
|
||||
*/
|
||||
fun fromRaw(
|
||||
rawPlaylist: RawPlaylist,
|
||||
deviceLibrary: DeviceLibrary,
|
||||
musicSettings: MusicSettings
|
||||
) =
|
||||
PlaylistImpl(
|
||||
rawPlaylist.playlistInfo.playlistUid,
|
||||
rawPlaylist.playlistInfo.name,
|
||||
SortName(rawPlaylist.playlistInfo.name, musicSettings),
|
||||
rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,23 +45,46 @@ interface UserLibrary {
|
|||
fun findPlaylist(uid: Music.UID): Playlist?
|
||||
|
||||
/** Constructs a [UserLibrary] implementation in an asynchronous manner. */
|
||||
interface Provider {
|
||||
interface Factory {
|
||||
/**
|
||||
* Create a new [UserLibrary].
|
||||
*
|
||||
* @param deviceLibrary Asynchronously populated [DeviceLibrary] that can be obtained later.
|
||||
* This allows database information to be read before the actual instance is constructed.
|
||||
* @return A new [UserLibrary] with the required implementation.
|
||||
* @return A new [MutableUserLibrary] with the required implementation.
|
||||
*/
|
||||
suspend fun read(deviceLibrary: Channel<DeviceLibrary>): UserLibrary
|
||||
suspend fun read(deviceLibrary: Channel<DeviceLibrary>): MutableUserLibrary
|
||||
}
|
||||
}
|
||||
|
||||
class UserLibraryProviderImpl
|
||||
/**
|
||||
* A mutable instance of [UserLibrary]. Not meant for use outside of the music module. Use
|
||||
* [MusicRepository] instead.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface MutableUserLibrary : UserLibrary {
|
||||
/**
|
||||
* Make a new [Playlist].
|
||||
*
|
||||
* @param name The name of the [Playlist].
|
||||
* @param songs The songs to place in the [Playlist].
|
||||
*/
|
||||
fun createPlaylist(name: String, songs: List<Song>)
|
||||
|
||||
/**
|
||||
* Add [Song]s to a [Playlist].
|
||||
*
|
||||
* @param playlist The [Playlist] to add to. Must currently exist.
|
||||
*/
|
||||
fun addToPlaylist(playlist: Playlist, songs: List<Song>)
|
||||
}
|
||||
|
||||
class UserLibraryFactoryImpl
|
||||
@Inject
|
||||
constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) :
|
||||
UserLibrary.Provider {
|
||||
override suspend fun read(deviceLibrary: Channel<DeviceLibrary>): UserLibrary =
|
||||
UserLibrary.Factory {
|
||||
override suspend fun read(deviceLibrary: Channel<DeviceLibrary>): MutableUserLibrary =
|
||||
UserLibraryImpl(playlistDao, deviceLibrary.receive(), musicSettings)
|
||||
}
|
||||
|
||||
|
@ -69,15 +92,28 @@ private class UserLibraryImpl(
|
|||
private val playlistDao: PlaylistDao,
|
||||
private val deviceLibrary: DeviceLibrary,
|
||||
private val musicSettings: MusicSettings
|
||||
) : UserLibrary {
|
||||
) : MutableUserLibrary {
|
||||
private val playlistMap = mutableMapOf<Music.UID, PlaylistImpl>()
|
||||
override val playlists: List<Playlist>
|
||||
get() = playlistMap.values.toList()
|
||||
|
||||
init {
|
||||
val playlist = PlaylistImpl("Playlist 1", deviceLibrary.songs.slice(58..100), musicSettings)
|
||||
playlistMap[playlist.uid] = playlist
|
||||
// TODO: Actually read playlists
|
||||
createPlaylist("Playlist 1", deviceLibrary.songs.slice(58..100))
|
||||
}
|
||||
|
||||
override fun findPlaylist(uid: Music.UID) = playlistMap[uid]
|
||||
|
||||
@Synchronized
|
||||
override fun createPlaylist(name: String, songs: List<Song>) {
|
||||
val playlistImpl = PlaylistImpl.new(name, songs, musicSettings)
|
||||
playlistMap[playlistImpl.uid] = playlistImpl
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun addToPlaylist(playlist: Playlist, songs: List<Song>) {
|
||||
val playlistImpl =
|
||||
requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" }
|
||||
playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ import dagger.hilt.components.SingletonComponent
|
|||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface UserModule {
|
||||
@Binds fun userLibaryProvider(provider: UserLibraryProviderImpl): UserLibrary.Provider
|
||||
@Binds fun userLibaryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory
|
||||
}
|
||||
|
||||
@Module
|
||||
|
|
Loading…
Reference in a new issue