diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2556181..14f8092d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 3.1.2 + +#### What's Improved +- `artistssort`, `albumartistssort`, and `album_artists` tags are now recognized +- Non-english digit strings are sorted more correctly +- Reduced visual loading time +- Genre/artist/album information is now obtained by specific child items + +#### What's Fixed +- Disc number is no longer mis-aligned when no subtitle is present +- Fixed selection not updating when playlists are changed +- Fixed duplicate albums appearing in certain cases +- Fixed ReplayGain adjustment not applying at the start of a song in certain cases +- Music cache is no longer migrated between devices + ## 3.1.1 #### What's New diff --git a/README.md b/README.md index f5e5ebabe..ccb7bcb0d 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index cc1075aad..d0ddac776 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,8 +20,8 @@ android { defaultConfig { applicationId namespace - versionName "3.1.1" - versionCode 31 + versionName "3.1.2" + versionCode 32 minSdk 24 targetSdk 33 @@ -56,6 +56,7 @@ android { } } } + packagingOptions { jniLibs { excludes += ['**/kotlin/**', '**/okhttp3/**'] @@ -65,7 +66,6 @@ android { } } - buildFeatures { viewBinding true } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 81daa45e1..fd7b28a4a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,12 +24,12 @@ android:name=".Auxio" android:allowBackup="true" android:fullBackupContent="@xml/backup_descriptor" + android:dataExtractionRules="@xml/data_extraction_rules" android:icon="@mipmap/ic_launcher" android:label="@string/info_app_name" android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" android:theme="@style/Theme.Auxio.App" - android:dataExtractionRules="@xml/data_extraction_rules" android:appCategory="audio" android:enableOnBackInvokedCallback="true" tools:ignore="UnusedAttribute"> diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index ba09eddf0..ecc8f21f5 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -149,7 +149,7 @@ class MainFragment : logD("Configuring stacked bottom sheets") unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener { if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED && - queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { + queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) { // Playback sheet is expanded and queue sheet is collapsed, we can expand it. queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED } @@ -414,7 +414,7 @@ class MainFragment : val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { + if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) { // Playback sheet is not expanded and not hidden, we can expand it. logD("Expanding playback sheet") playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED @@ -424,9 +424,9 @@ class MainFragment : val queueSheetBehavior = (binding.queueSheet.coordinatorLayoutBehavior ?: return) as QueueBottomSheetBehavior if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED && - queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { + queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) { // Queue sheet and playback sheet is expanded, close the queue sheet so the - // playback panel can eb shown. + // playback panel can shown. logD("Collapsing queue sheet") queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED } @@ -436,7 +436,7 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { + if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) { // Playback sheet (and possibly queue) needs to be collapsed. logD("Collapsing playback and queue sheets") val queueSheetBehavior = @@ -487,8 +487,6 @@ class MainFragment : } } - // TODO: Use targetState more - private class SheetBackPressedCallback( private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>, private val queueSheetBehavior: QueueBottomSheetBehavior<*>? diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index f18321430..c63c76151 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -480,7 +480,7 @@ constructor( // implicit album list into the mapping. logD("Implicit albums present, adding to list") @Suppress("UNCHECKED_CAST") - (grouping as MutableMap>)[AlbumGrouping.APPEARANCES] = + (grouping as MutableMap>)[AlbumGrouping.APPEARANCES] = artist.implicitAlbums } @@ -490,7 +490,7 @@ constructor( val header = BasicHeader(entry.key.headerTitleRes) list.add(Divider(header)) list.add(header) - list.addAll(entry.value) + list.addAll(ARTIST_ALBUM_SORT.albums(entry.value)) } // Artists may not be linked to any songs, only include a header entry if we have any. @@ -519,7 +519,7 @@ constructor( val artistHeader = BasicHeader(R.string.lbl_artists) list.add(Divider(artistHeader)) list.add(artistHeader) - list.addAll(genre.artists) + list.addAll(GENRE_ARTIST_SORT.artists(genre.artists)) val songHeader = SortHeader(R.string.lbl_songs) list.add(Divider(songHeader)) @@ -576,4 +576,9 @@ constructor( LIVE(R.string.lbl_live_group), REMIXES(R.string.lbl_remix_group), } + + private companion object { + val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) + val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt index 66fc29d7c..63419e1e5 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt @@ -115,7 +115,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number) binding.discName.apply { text = disc.name - isGone = text == null + isGone = disc.name == null } } else { logD("Disc is null, defaulting to no disc") 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 f1f856684..f39a54e1f 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -52,6 +52,7 @@ import org.oxycblt.auxio.home.list.GenreListFragment import org.oxycblt.auxio.home.list.PlaylistListFragment import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy +import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.list.selection.SelectionViewModel @@ -152,9 +153,10 @@ class HomeFragment : setOnApplyWindowInsetsListener { _, insets -> insets } // We know that there will only be a fixed amount of tabs, so we manually set this - // limit to that. This also prevents the appbar lift state from being confused during - // page transitions. - offscreenPageLimit = homeModel.currentTabModes.size + // limit to the maximum amount possible. This will prevent the tab ripple from + // bugging out due to dynamically inflating each fragment, at the cost of slower + // debug UI performance. + offscreenPageLimit = Tab.MAX_SEQUENCE_IDX + 1 // By default, ViewPager2's sensitivity is high enough to result in vertical scroll // events being registered as horizontal scroll events. Reflect into the internal diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt index 2fddd1b4a..5cacd084b 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt @@ -59,7 +59,7 @@ sealed class Tab(open val mode: MusicMode) { // MusicMode for this tab. /** The maximum index that a well-formed tab sequence should be. */ - private const val MAX_SEQUENCE_IDX = 4 + const val MAX_SEQUENCE_IDX = 4 /** * The default tab sequence, in integer form. This represents a set of four visible tabs diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt index a4d0e6917..b2912595c 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -379,7 +379,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param desc The content description to describe the bound data. * @param errorRes The resource of the error drawable to use if the cover cannot be loaded. */ - fun bind(songs: List, desc: String, @DrawableRes errorRes: Int) { + fun bind(songs: Collection, desc: String, @DrawableRes errorRes: Int) { val request = ImageRequest.Builder(context) .data(songs) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 017a76747..b7a7183db 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -27,22 +27,22 @@ import javax.inject.Inject import org.oxycblt.auxio.music.Song class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) : - Keyer> { - override fun key(data: List, options: Options) = + Keyer> { + override fun key(data: Collection, options: Options) = "${coverExtractor.computeCoverOrdering(data).hashCode()}" } class SongCoverFetcher private constructor( - private val songs: List, + private val songs: Collection, private val size: Size, private val coverExtractor: CoverExtractor, ) : Fetcher { override suspend fun fetch() = coverExtractor.extract(songs, size) class Factory @Inject constructor(private val coverExtractor: CoverExtractor) : - Fetcher.Factory> { - override fun create(data: List, options: Options, imageLoader: ImageLoader) = + Fetcher.Factory> { + override fun create(data: Collection, options: Options, imageLoader: ImageLoader) = SongCoverFetcher(data, options.size, coverExtractor) } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index 067b8361c..6ca126256 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -77,7 +77,7 @@ constructor( * will be returned of a mosaic composed of four album covers ordered by * [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned. */ - suspend fun extract(songs: List, size: Size): FetchResult? { + suspend fun extract(songs: Collection, size: Size): FetchResult? { val albums = computeCoverOrdering(songs) val streams = mutableListOf() for (album in albums) { @@ -117,7 +117,7 @@ constructor( * by their names. "Representation" is defined by how many [Song]s were found to be linked to * the given [Album] in the given [Song] list. */ - fun computeCoverOrdering(songs: List): List { + fun computeCoverOrdering(songs: Collection): List { // TODO: Start short-circuiting in more places if (songs.isEmpty()) return listOf() if (songs.size == 1) return listOf(songs.first().album) @@ -150,7 +150,7 @@ constructor( MediaMetadataRetriever().run { // This call is time-consuming but it also doesn't seem to hold up the main thread, // so it's probably fine not to wrap it.rmt - setDataSource(context, album.songs[0].uri) + setDataSource(context, album.coverUri.song) // Get the embedded picture from MediaMetadataRetriever, which will return a full // ByteArray of the cover without any compression artifacts. @@ -161,7 +161,7 @@ constructor( private suspend fun extractExoplayerCover(album: Album): InputStream? { val tracks = MetadataRetriever.retrieveMetadata( - mediaSourceFactory, MediaItem.fromUri(album.songs[0].uri)) + mediaSourceFactory, MediaItem.fromUri(album.coverUri.song)) .asDeferred() .await() @@ -207,7 +207,9 @@ constructor( private suspend fun extractMediaStoreCover(album: Album) = // Eliminate any chance that this blocking call might mess up the loading process - withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) } + withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(album.coverUri.mediaStore) + } /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */ private fun createMosaic(streams: List, size: Size): FetchResult { diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt new file mode 100644 index 000000000..5e32d09ff --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Auxio Project + * CoverUri.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.image.extractor + +import android.net.Uri + +/** + * Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading + * images. + * + * @param mediaStore The album cover [Uri] obtained from MediaStore. + * @param song The [Uri] of the first song (by track) of the album, which can also be used to obtain + * an album cover. + * @author Alexander Capehart (OxygenCobalt) + */ +data class CoverUri(val mediaStore: Uri, val song: Uri) diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index a5cfd776e..3f4efdd0e 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt @@ -56,7 +56,6 @@ constructor( } override fun onMusicChanges(changes: MusicRepository.Changes) { - if (!changes.deviceLibrary) return val deviceLibrary = musicRepository.deviceLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return // Sanitize the selection to remove items that no longer exist and thus 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 a9783cfae..fc8a51390 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -27,6 +27,7 @@ import java.util.UUID import kotlin.math.max import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import org.oxycblt.auxio.image.extractor.CoverUri import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.Path @@ -34,6 +35,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.ReleaseType +import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment import org.oxycblt.auxio.util.concatLocalized import org.oxycblt.auxio.util.toUuidOrNull @@ -224,7 +226,7 @@ sealed interface Music : Item { */ sealed interface MusicParent : Music { /** The child [Song]s of this [MusicParent]. */ - val songs: List + val songs: Collection } /** @@ -255,6 +257,8 @@ interface Song : Music { val size: Long /** The duration of the audio file, in milliseconds. */ val durationMs: Long + /** The ReplayGain adjustment to apply during playback. */ + val replayGainAdjustment: ReplayGainAdjustment /** The date the audio file was added to the device, as a unix epoch timestamp. */ val dateAdded: Long /** @@ -293,7 +297,7 @@ interface Album : MusicParent { * The URI to a MediaStore-provided album cover. These images will be fast to load, but at the * cost of image quality. */ - val coverUri: Uri + val coverUri: CoverUri /** The duration of all songs in the album, in milliseconds. */ val durationMs: Long /** The earliest date a song in this album was added, as a unix epoch timestamp. */ @@ -318,14 +322,11 @@ interface Artist : MusicParent { * Note that any [Song] credited to this artist will have it's [Album] considered to be * "indirectly" linked to this [Artist], and thus included in this list. */ - val albums: List - + val albums: Collection /** Albums directly credited to this [Artist] via a "Album Artist" tag. */ - val explicitAlbums: List - + val explicitAlbums: Collection /** Albums indirectly credited to this [Artist] via an "Artist" tag. */ - val implicitAlbums: List - + val implicitAlbums: Collection /** * The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no * songs. @@ -342,7 +343,7 @@ interface Artist : MusicParent { */ interface Genre : MusicParent { /** The artists indirectly linked to by the [Artist]s of this [Genre]. */ - val artists: List + val artists: Collection /** The total duration of the songs in this genre, in milliseconds. */ val durationMs: Long } @@ -353,6 +354,7 @@ interface Genre : MusicParent { * @author Alexander Capehart (OxygenCobalt) */ interface Playlist : MusicParent { + override val songs: List /** The total duration of the songs in this genre, in milliseconds. */ val durationMs: Long } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 8d890b218..513786f87 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.device.DeviceLibrary @@ -300,35 +301,35 @@ constructor( val userLibrary = synchronized(this) { userLibrary ?: return } logD("Creating playlist $name with ${songs.size} songs") userLibrary.createPlaylist(name, songs) - emitLibraryChange(device = false, user = true) + dispatchLibraryChange(device = false, user = true) } override suspend fun renamePlaylist(playlist: Playlist, name: String) { val userLibrary = synchronized(this) { userLibrary ?: return } logD("Renaming $playlist to $name") userLibrary.renamePlaylist(playlist, name) - emitLibraryChange(device = false, user = true) + dispatchLibraryChange(device = false, user = true) } override suspend fun deletePlaylist(playlist: Playlist) { val userLibrary = synchronized(this) { userLibrary ?: return } logD("Deleting $playlist") userLibrary.deletePlaylist(playlist) - emitLibraryChange(device = false, user = true) + dispatchLibraryChange(device = false, user = true) } override suspend fun addToPlaylist(songs: List, playlist: Playlist) { val userLibrary = synchronized(this) { userLibrary ?: return } logD("Adding ${songs.size} songs to $playlist") userLibrary.addToPlaylist(playlist, songs) - emitLibraryChange(device = false, user = true) + dispatchLibraryChange(device = false, user = true) } override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { val userLibrary = synchronized(this) { userLibrary ?: return } logD("Rewriting $playlist with ${songs.size} songs") userLibrary.rewritePlaylist(playlist, songs) - emitLibraryChange(device = false, user = true) + dispatchLibraryChange(device = false, user = true) } @Synchronized @@ -353,7 +354,7 @@ constructor( // Music loading process failed due to something we have not handled. logE("Music indexing failed") logE(e.stackTraceToString()) - emitComplete(e) + emitIndexingCompletion(e) } } @@ -367,7 +368,7 @@ constructor( // Start initializing the extractors. Use an indeterminate state, as there is no ETA on // how long a media database query will take. - emitLoading(IndexingProgress.Indeterminate) + emitIndexingProgress(IndexingProgress.Indeterminate) // Do the initial query of the cache and media databases in parallel. logD("Starting MediaStore query") @@ -388,6 +389,7 @@ constructor( logD("Starting song discovery") val completeSongs = Channel(Channel.UNLIMITED) val incompleteSongs = Channel(Channel.UNLIMITED) + val processedSongs = Channel(Channel.UNLIMITED) logD("Started MediaStore discovery") val mediaStoreJob = worker.scope.tryAsync { @@ -400,12 +402,19 @@ constructor( tagExtractor.consume(incompleteSongs, completeSongs) completeSongs.close() } + logD("Starting DeviceLibrary creation") + val deviceLibraryJob = + worker.scope.tryAsync(Dispatchers.Default) { + deviceLibraryFactory.create(completeSongs, processedSongs).also { + processedSongs.close() + } + } // Await completed raw songs as they are processed. val rawSongs = LinkedList() - for (rawSong in completeSongs) { + for (rawSong in processedSongs) { rawSongs.add(rawSong) - emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) + emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) } logD("Awaiting discovery completion") // These should be no-ops, but we need the error state to see if we should keep going. @@ -417,47 +426,45 @@ constructor( throw NoMusicException() } - // Successfully loaded the library, now save the cache, create the library, and - // read playlist information in parallel. + // Successfully loaded the library, now save the cache and read playlist information + // in parallel. logD("Discovered ${rawSongs.size} songs, starting finalization") - // TODO: Indicate playlist state in loading process? - emitLoading(IndexingProgress.Indeterminate) - val deviceLibraryChannel = Channel() - logD("Starting DeviceLibrary creation") - val deviceLibraryJob = - worker.scope.tryAsync(Dispatchers.Default) { - deviceLibraryFactory.create(rawSongs).also { deviceLibraryChannel.send(it) } - } - logD("Starting UserLibrary creation") - val userLibraryJob = - worker.scope.tryAsync { - userLibraryFactory.read(deviceLibraryChannel).also { deviceLibraryChannel.close() } - } + emitIndexingProgress(IndexingProgress.Indeterminate) + logD("Starting UserLibrary query") + val userLibraryQueryJob = worker.scope.tryAsync { userLibraryFactory.query() } if (cache == null || cache.invalidated) { logD("Writing cache [why=${cache?.invalidated}]") cacheRepository.writeCache(rawSongs) } - logD("Awaiting library creation") + logD("Awaiting UserLibrary query") + val rawPlaylists = userLibraryQueryJob.await().getOrThrow() + logD("Awaiting DeviceLibrary creation") val deviceLibrary = deviceLibraryJob.await().getOrThrow() - val userLibrary = userLibraryJob.await().getOrThrow() + logD("Starting UserLibrary creation") + val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary) logD("Successfully indexed music library [device=$deviceLibrary user=$userLibrary]") - emitComplete(null) + emitIndexingCompletion(null) // Comparing the library instances is obscenely expensive, do it within the library - val deviceLibraryChanged = this.deviceLibrary != deviceLibrary - val userLibraryChanged = this.userLibrary != userLibrary - if (!deviceLibraryChanged && !userLibraryChanged) { - logD("Library has not changed, skipping update") - return - } + val deviceLibraryChanged: Boolean + val userLibraryChanged: Boolean synchronized(this) { + deviceLibraryChanged = this.deviceLibrary != deviceLibrary + userLibraryChanged = this.userLibrary != userLibrary + if (!deviceLibraryChanged && !userLibraryChanged) { + logD("Library has not changed, skipping update") + return + } + this.deviceLibrary = deviceLibrary this.userLibrary = userLibrary } - emitLibraryChange(deviceLibraryChanged, userLibraryChanged) + withContext(Dispatchers.Main) { + dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged) + } } /** @@ -476,7 +483,7 @@ constructor( } } - private suspend fun emitLoading(progress: IndexingProgress) { + private suspend fun emitIndexingProgress(progress: IndexingProgress) { yield() synchronized(this) { currentIndexingState = IndexingState.Indexing(progress) @@ -486,7 +493,7 @@ constructor( } } - private suspend fun emitComplete(error: Exception?) { + private suspend fun emitIndexingCompletion(error: Exception?) { yield() synchronized(this) { previousCompletedState = IndexingState.Completed(error) @@ -499,7 +506,7 @@ constructor( } @Synchronized - private fun emitLibraryChange(device: Boolean, user: Boolean) { + private fun dispatchLibraryChange(device: Boolean, user: Boolean) { val changes = MusicRepository.Changes(device, user) logD("Dispatching library change [changes=$changes]") for (listener in updateListeners) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 2cf6d33c0..7d1ac68d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -32,19 +32,19 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 27, exportSchema = false) +@Database(entities = [CachedSong::class], version = 32, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } @Dao interface CachedSongsDao { - @Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readSongs(): List - @Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs() + @Query("SELECT * FROM CachedSong") suspend fun readSongs(): List + @Query("DELETE FROM CachedSong") suspend fun nukeSongs() @Insert suspend fun insertSongs(songs: List) } -@Entity(tableName = CachedSong.TABLE_NAME) +@Entity @TypeConverters(CachedSong.Converters::class) data class CachedSong( /** @@ -60,6 +60,10 @@ data class CachedSong( var size: Long? = null, /** @see RawSong */ var durationMs: Long, + /** @see RawSong.replayGainTrackAdjustment */ + val replayGainTrackAdjustment: Float?, + /** @see RawSong.replayGainAlbumAdjustment */ + val replayGainAlbumAdjustment: Float?, /** @see RawSong.musicBrainzId */ var musicBrainzId: String? = null, /** @see RawSong.name */ @@ -97,7 +101,7 @@ data class CachedSong( /** @see RawSong.genreNames */ var genreNames: List = listOf() ) { - fun copyToRaw(rawSong: RawSong): CachedSong { + fun copyToRaw(rawSong: RawSong) { rawSong.musicBrainzId = musicBrainzId rawSong.name = name rawSong.sortName = sortName @@ -105,6 +109,9 @@ data class CachedSong( rawSong.size = size rawSong.durationMs = durationMs + rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment + rawSong.replayGainAlbumAdjustment = replayGainAlbumAdjustment + rawSong.track = track rawSong.disc = disc rawSong.subtitle = subtitle @@ -124,7 +131,6 @@ data class CachedSong( rawSong.albumArtistSortNames = albumArtistSortNames rawSong.genreNames = genreNames - return this } object Converters { @@ -141,8 +147,6 @@ data class CachedSong( } companion object { - const val TABLE_NAME = "cached_songs" - fun fromRaw(rawSong: RawSong) = CachedSong( mediaStoreId = @@ -155,6 +159,8 @@ data class CachedSong( sortName = rawSong.sortName, size = rawSong.size, durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" }, + replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment, + replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment, track = rawSong.track, disc = rawSong.disc, subtitle = rawSong.subtitle, diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt index 82e70f217..16de62c99 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt @@ -43,8 +43,6 @@ class CacheRoomModule { Room.databaseBuilder( context.applicationContext, CacheDatabase::class.java, "music_cache.db") .fallbackToDestructiveMigration() - .fallbackToDestructiveMigrationFrom(0) - .fallbackToDestructiveMigrationOnDowngrade() .build() @Provides fun cachedSongsDao(database: CacheDatabase) = database.cachedSongsDao() diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 814c15818..8032c46d7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -22,7 +22,7 @@ import android.content.Context import android.net.Uri import android.provider.OpenableColumns import javax.inject.Inject -import org.oxycblt.auxio.list.Sort +import kotlinx.coroutines.channels.Channel import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -32,7 +32,8 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.useQuery -import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.unlikelyToBeNull /** * Organized music library information obtained from device storage. @@ -45,13 +46,13 @@ import org.oxycblt.auxio.util.logD */ interface DeviceLibrary { /** All [Song]s in this [DeviceLibrary]. */ - val songs: List + val songs: Collection /** All [Album]s in this [DeviceLibrary]. */ - val albums: List + val albums: Collection /** All [Artist]s in this [DeviceLibrary]. */ - val artists: List + val artists: Collection /** All [Genre]s in this [DeviceLibrary]. */ - val genres: List + val genres: Collection /** * Find a [Song] instance corresponding to the given [Music.UID]. @@ -97,37 +98,166 @@ interface DeviceLibrary { /** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */ interface Factory { /** - * Create a new [DeviceLibrary]. + * Creates a new [DeviceLibrary] instance asynchronously based on the incoming stream of + * [RawSong] instances. * - * @param rawSongs [RawSong] instances to create a [DeviceLibrary] from. + * @param rawSongs A stream of [RawSong] instances to process. + * @param processedSongs A stream of [RawSong] instances that will have been processed by + * the instance. */ - suspend fun create(rawSongs: List): DeviceLibrary - } - - companion object { - /** - * Create an instance of [DeviceLibrary]. - * - * @param rawSongs [RawSong]s to create the library out of. - * @param settings [MusicSettings] required. - */ - fun from(rawSongs: List, settings: MusicSettings): DeviceLibrary = - DeviceLibraryImpl(rawSongs, settings) + suspend fun create( + rawSongs: Channel, + processedSongs: Channel + ): DeviceLibraryImpl } } class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) : DeviceLibrary.Factory { - override suspend fun create(rawSongs: List): DeviceLibrary = - DeviceLibraryImpl(rawSongs, musicSettings) + override suspend fun create( + rawSongs: Channel, + processedSongs: Channel + ): DeviceLibraryImpl { + val songGrouping = mutableMapOf() + val albumGrouping = mutableMapOf>() + val artistGrouping = mutableMapOf>() + val genreGrouping = mutableMapOf>() + + // TODO: Use comparators here + + // All music information is grouped as it is indexed by other components. + for (rawSong in rawSongs) { + val song = SongImpl(rawSong, musicSettings) + // At times the indexer produces duplicate songs, try to filter these. Comparing by + // UID is sufficient for something like this, and also prevents collisions from + // causing severe issues elsewhere. + if (songGrouping.containsKey(song.uid)) { + logW( + "Duplicate song found: ${song.path} " + + "collides with ${unlikelyToBeNull(songGrouping[song.uid]).path}") + // We still want to say that we "processed" the song so that the user doesn't + // get confused at why the bar was only partly filled by the end of the loading + // process. + processedSongs.send(rawSong) + continue + } + songGrouping[song.uid] = song + + // Group the new song into an album. + val albumKey = song.rawAlbum.key + val albumBody = albumGrouping[albumKey] + if (albumBody != null) { + albumBody.music.add(song) + val prioritized = albumBody.raw.src + // Since albums are grouped fuzzily, we pick the song with the earliest track to + // use for album information to ensure consistent metadata and UIDs. Fall back to + // the name otherwise. + val higherPriority = + song.track != null && + (prioritized.track == null || + song.track < prioritized.track || + (song.track == prioritized.track && song.name < prioritized.name)) + + if (higherPriority) { + albumBody.raw = PrioritizedRaw(song.rawAlbum, song) + } + } else { + // Need to initialize this grouping. + albumGrouping[albumKey] = + Grouping(PrioritizedRaw(song.rawAlbum, song), mutableSetOf(song)) + } + + // Group the song into each of it's artists. + for (rawArtist in song.rawArtists) { + val artistKey = rawArtist.key + val artistBody = artistGrouping[artistKey] + if (artistBody != null) { + // Since artists are not guaranteed to have songs, song artist information is + // de-prioritized compared to album artist information. + artistBody.music.add(song) + } else { + // Need to initialize this grouping. + artistGrouping[artistKey] = + Grouping(PrioritizedRaw(rawArtist, song), mutableSetOf(song)) + } + } + + // Group the song into each of it's genres. + for (rawGenre in song.rawGenres) { + val genreKey = rawGenre.key + val genreBody = genreGrouping[genreKey] + if (genreBody != null) { + genreBody.music.add(song) + // Genre information from higher songs in ascending alphabetical order are + // prioritized. + val prioritized = genreBody.raw.src + val higherPriority = song.name < prioritized.name + if (higherPriority) { + genreBody.raw = PrioritizedRaw(rawGenre, song) + } + } else { + // Need to initialize this grouping. + genreGrouping[genreKey] = + Grouping(PrioritizedRaw(rawGenre, song), mutableSetOf(song)) + } + } + + processedSongs.send(rawSong) + } + + // Now that all songs are processed, also process albums and group them into their + // respective artists. + val albums = albumGrouping.values.mapTo(mutableSetOf()) { AlbumImpl(it, musicSettings) } + for (album in albums) { + for (rawArtist in album.rawArtists) { + val key = RawArtist.Key(rawArtist) + val body = artistGrouping[key] + if (body != null) { + body.music.add(album) + when (val prioritized = body.raw.src) { + // Immediately replace any songs that initially held the priority position. + is SongImpl -> body.raw = PrioritizedRaw(rawArtist, album) + is AlbumImpl -> { + // Album artist information from earlier dates is prioritized, as it is + // less likely to change with the addition of new tracks. Fall back to + // the name otherwise. + val prioritize = + album.dates != null && + (prioritized.dates == null || + album.dates < prioritized.dates || + (album.dates == prioritized.dates && + album.name < prioritized.name)) + + if (prioritize) { + body.raw = PrioritizedRaw(rawArtist, album) + } + } + else -> throw IllegalStateException() + } + } else { + // Need to initialize this grouping. + artistGrouping[key] = + Grouping(PrioritizedRaw(rawArtist, album), mutableSetOf(album)) + } + } + } + + // Artists and genres do not need to be grouped and can be processed immediately. + val artists = artistGrouping.values.mapTo(mutableSetOf()) { ArtistImpl(it, musicSettings) } + val genres = genreGrouping.values.mapTo(mutableSetOf()) { GenreImpl(it, musicSettings) } + + return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres) + } } -private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings) : DeviceLibrary { - override val songs = buildSongs(rawSongs, settings) - override val albums = buildAlbums(songs, settings) - override val artists = buildArtists(songs, albums, settings) - override val genres = buildGenres(songs, settings) +// TODO: Avoid redundant data creation +class DeviceLibraryImpl( + override val songs: Set, + override val albums: Set, + override val artists: Set, + override val genres: Set +) : DeviceLibrary { // Use a mapping to make finding information based on it's UID much faster. private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } } private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } } @@ -138,12 +268,13 @@ private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs override fun hashCode() = songs.hashCode() override fun toString() = - "DeviceLibrary(songs=${songs.size}, albums=${albums.size}, artists=${artists.size}, genres=${genres.size})" + "DeviceLibrary(songs=${songs.size}, albums=${albums.size}, " + + "artists=${artists.size}, genres=${genres.size})" - override fun findSong(uid: Music.UID) = songUidMap[uid] - override fun findAlbum(uid: Music.UID) = albumUidMap[uid] - override fun findArtist(uid: Music.UID) = artistUidMap[uid] - override fun findGenre(uid: Music.UID) = genreUidMap[uid] + override fun findSong(uid: Music.UID): Song? = songUidMap[uid] + override fun findAlbum(uid: Music.UID): Album? = albumUidMap[uid] + override fun findArtist(uid: Music.UID): Artist? = artistUidMap[uid] + override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid] override fun findSongForUri(context: Context, uri: Uri) = context.contentResolverSafe.useQuery( @@ -156,70 +287,4 @@ private class DeviceLibraryImpl(rawSongs: List, settings: MusicSettings val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) songs.find { it.path.name == displayName && it.size == size } } - - private fun buildSongs(rawSongs: List, settings: MusicSettings): List { - val start = System.currentTimeMillis() - val songs = - Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - .songs(rawSongs.map { SongImpl(it, settings) }.distinctBy { it.uid }) - logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms") - return songs - } - - private fun buildAlbums(songs: List, settings: MusicSettings): List { - val start = System.currentTimeMillis() - // Group songs by their singular raw album, then map the raw instances and their - // grouped songs to Album values. Album.Raw will handle the actual grouping rules. - val songsByAlbum = songs.groupBy { it.rawAlbum.key } - val albums = songsByAlbum.map { AlbumImpl(it.key.value, settings, it.value) } - logD("Successfully built ${albums.size} albums in ${System.currentTimeMillis() - start}ms") - return albums - } - - private fun buildArtists( - songs: List, - albums: List, - settings: MusicSettings - ): List { - val start = System.currentTimeMillis() - // Add every raw artist credited to each Song/Album to the grouping. This way, - // different multi-artist combinations are not treated as different artists. - // Songs and albums are grouped by artist and album artist respectively. - val musicByArtist = mutableMapOf>() - - for (song in songs) { - for (rawArtist in song.rawArtists) { - musicByArtist.getOrPut(rawArtist.key) { mutableListOf() }.add(song) - } - } - - for (album in albums) { - for (rawArtist in album.rawArtists) { - musicByArtist.getOrPut(rawArtist.key) { mutableListOf() }.add(album) - } - } - - // Convert the combined mapping into artist instances. - val artists = musicByArtist.map { ArtistImpl(it.key.value, settings, it.value) } - logD( - "Successfully built ${artists.size} artists in ${System.currentTimeMillis() - start}ms") - return artists - } - - private fun buildGenres(songs: List, settings: MusicSettings): List { - val start = System.currentTimeMillis() - // Add every raw genre credited to each Song to the grouping. This way, - // different multi-genre combinations are not treated as different genres. - val songsByGenre = mutableMapOf>() - for (song in songs) { - for (rawGenre in song.rawGenres) { - songsByGenre.getOrPut(rawGenre.key) { mutableListOf() }.add(song) - } - } - - // Convert the mapping into genre instances. - val genres = songsByGenre.map { GenreImpl(it.key.value, settings, it.value) } - logD("Successfully built ${genres.size} genres in ${System.currentTimeMillis() - start}ms") - return genres - } } 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 e3ce99928..9cb4d70b5 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 @@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.device import org.oxycblt.auxio.R +import org.oxycblt.auxio.image.extractor.CoverUri import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -37,6 +38,7 @@ import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.metadata.parseId3GenreNames import org.oxycblt.auxio.music.metadata.parseMultiValue +import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.unlikelyToBeNull @@ -88,6 +90,10 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son fromFormat = null) override val size = requireNotNull(rawSong.size) { "Invalid raw: No size" } override val durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" } + override val replayGainAdjustment = + ReplayGainAdjustment( + track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment) + override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" } private var _album: AlbumImpl? = null override val album: Album @@ -226,17 +232,16 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son /** * Library-backed implementation of [Album]. * - * @param rawAlbum The [RawAlbum] to derive the member data from. + * @param grouping [Grouping] to derive the member data from. * @param musicSettings [MusicSettings] to for user parsing configuration. - * @param songs The [Song]s that are a part of this [Album]. These items will be linked to this - * [Album]. * @author Alexander Capehart (OxygenCobalt) */ class AlbumImpl( - private val rawAlbum: RawAlbum, + grouping: Grouping, musicSettings: MusicSettings, - override val songs: List ) : Album { + private val rawAlbum = grouping.raw.inner + 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) } @@ -250,7 +255,7 @@ class AlbumImpl( override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings) override val dates: Date.Range? override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) - override val coverUri = rawAlbum.mediaStoreId.toCoverUri() + override val coverUri = CoverUri(rawAlbum.mediaStoreId.toCoverUri(), grouping.raw.src.uri) override val durationMs: Long override val dateAdded: Long @@ -258,6 +263,8 @@ class AlbumImpl( override val artists: List get() = _artists + override val songs: Set = grouping.music + private var hashCode = uid.hashCode() init { @@ -267,7 +274,7 @@ class AlbumImpl( var earliestDateAdded: Long = Long.MAX_VALUE // Do linking and value generation in the same loop for efficiency. - for (song in songs) { + for (song in grouping.music) { song.link(this) if (song.date != null) { @@ -342,18 +349,13 @@ class AlbumImpl( /** * Library-backed implementation of [Artist]. * - * @param rawArtist The [RawArtist] to derive the member data from. + * @param grouping [Grouping] to derive the member data from. * @param musicSettings [MusicSettings] to for user parsing configuration. - * @param songAlbums A list of the [Song]s and [Album]s that are a part of this [Artist] , either - * through artist or album artist tags. Providing [Song]s to the artist is optional. These - * instances will be linked to this [Artist]. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistImpl( - private val rawArtist: RawArtist, - musicSettings: MusicSettings, - songAlbums: List -) : Artist { +class ArtistImpl(grouping: Grouping, musicSettings: MusicSettings) : Artist { + private val rawArtist = grouping.raw.inner + 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) } @@ -362,10 +364,10 @@ class ArtistImpl( rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) } ?: Name.Unknown(R.string.def_artist) - override val songs: List - override val albums: List - override val explicitAlbums: List - override val implicitAlbums: List + override val songs: Set + override val albums: Set + override val explicitAlbums: Set + override val implicitAlbums: Set override val durationMs: Long? override lateinit var genres: List @@ -376,7 +378,7 @@ class ArtistImpl( val distinctSongs = mutableSetOf() val albumMap = mutableMapOf() - for (music in songAlbums) { + for (music in grouping.music) { when (music) { is SongImpl -> { music.link(this) @@ -393,10 +395,10 @@ class ArtistImpl( } } - songs = distinctSongs.toList() - albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(albumMap.keys) - explicitAlbums = albums.filter { unlikelyToBeNull(albumMap[it]) } - implicitAlbums = albums.filterNot { unlikelyToBeNull(albumMap[it]) } + songs = distinctSongs + albums = albumMap.keys + explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true } + implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true } durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull() hashCode = 31 * hashCode + rawArtist.hashCode() @@ -444,40 +446,38 @@ class ArtistImpl( /** * Library-backed implementation of [Genre]. * - * @param rawGenre [RawGenre] to derive the member data from. + * @param grouping [Grouping] to derive the member data from. * @param musicSettings [MusicSettings] to for user parsing configuration. - * @param songs Child [SongImpl]s of this instance. * @author Alexander Capehart (OxygenCobalt) */ -class GenreImpl( - private val rawGenre: RawGenre, - musicSettings: MusicSettings, - override val songs: List -) : Genre { +class GenreImpl(grouping: Grouping, musicSettings: MusicSettings) : Genre { + private val rawGenre = grouping.raw.inner + 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) - override val artists: List + override val songs: Set + override val artists: Set override val durationMs: Long private var hashCode = uid.hashCode() init { - val distinctAlbums = mutableSetOf() val distinctArtists = mutableSetOf() var totalDuration = 0L - for (song in songs) { + for (song in grouping.music) { song.link(this) - distinctAlbums.add(song.album) distinctArtists.addAll(song.artists) totalDuration += song.durationMs } - artists = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).artists(distinctArtists) + songs = grouping.music + artists = distinctArtists durationMs = totalDuration + hashCode = 31 * hashCode + rawGenre.hashCode() hashCode = 31 * hashCode + songs.hashCode() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index 2a198c687..73fa3c753 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -51,6 +51,10 @@ data class RawSong( var durationMs: Long? = null, /** @see Song.mimeType */ var extensionMimeType: String? = null, + /** @see Song.replayGainAdjustment */ + var replayGainTrackAdjustment: Float? = null, + /** @see Song.replayGainAdjustment */ + var replayGainAlbumAdjustment: Float? = null, /** @see Music.UID */ var musicBrainzId: String? = null, /** @see Music.name */ @@ -115,30 +119,33 @@ data class RawAlbum( ) { val key = Key(this) - /** Exposed information that denotes [RawAlbum] uniqueness. */ - data class Key(val value: RawAlbum) { + /** + * Allows [RawAlbum]s to be compared by "fundamental" information that is unlikely to change on + * an item-by-item + */ + data class Key(private val inner: RawAlbum) { // Albums are grouped as follows: // - If we have a MusicBrainz ID, only group by it. This allows different Albums with the // same name to be differentiated, which is common in large libraries. // - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase // artist name. This allows for case-insensitive artist/album grouping, which can be common // for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein"). + private val artistKeys = inner.rawArtists.map { it.key } // Cache the hash-code for HashMap efficiency. private val hashCode = - value.musicBrainzId?.hashCode() - ?: (31 * value.name.lowercase().hashCode() + value.rawArtists.hashCode()) + inner.musicBrainzId?.hashCode() + ?: (31 * inner.name.lowercase().hashCode() + artistKeys.hashCode()) override fun hashCode() = hashCode override fun equals(other: Any?) = other is Key && when { - value.musicBrainzId != null && other.value.musicBrainzId != null -> - value.musicBrainzId == other.value.musicBrainzId - value.musicBrainzId == null && other.value.musicBrainzId == null -> - other.value.name.equals(other.value.name, true) && - other.value.rawArtists == other.value.rawArtists + inner.musicBrainzId != null && other.inner.musicBrainzId != null -> + inner.musicBrainzId == other.inner.musicBrainzId + inner.musicBrainzId == null && other.inner.musicBrainzId == null -> + inner.name.equals(other.inner.name, true) && artistKeys == other.artistKeys else -> false } } @@ -164,7 +171,7 @@ data class RawArtist( * Allows [RawArtist]s to be compared by "fundamental" information that is unlikely to change on * an item-by-item */ - data class Key(val value: RawArtist) { + data class Key(private val inner: RawArtist) { // Artists are grouped as follows: // - If we have a MusicBrainz ID, only group by it. This allows different Artists with the // same name to be differentiated, which is common in large libraries. @@ -172,7 +179,7 @@ data class RawArtist( // grouping to be case-insensitive. // Cache the hashCode for HashMap efficiency. - private val hashCode = value.musicBrainzId?.hashCode() ?: value.name?.lowercase().hashCode() + val hashCode = inner.musicBrainzId?.hashCode() ?: inner.name?.lowercase().hashCode() // Compare names and MusicBrainz IDs in order to differentiate artists with the // same name in large libraries. @@ -182,13 +189,13 @@ data class RawArtist( override fun equals(other: Any?) = other is Key && when { - value.musicBrainzId != null && other.value.musicBrainzId != null -> - value.musicBrainzId == other.value.musicBrainzId - value.musicBrainzId == null && other.value.musicBrainzId == null -> + inner.musicBrainzId != null && other.inner.musicBrainzId != null -> + inner.musicBrainzId == other.inner.musicBrainzId + inner.musicBrainzId == null && other.inner.musicBrainzId == null -> when { - value.name != null && other.value.name != null -> - value.name.equals(other.value.name, true) - value.name == null && other.value.name == null -> true + inner.name != null && other.inner.name != null -> + inner.name.equals(other.inner.name, true) + inner.name == null && other.inner.name == null -> true else -> false } else -> false @@ -207,9 +214,13 @@ data class RawGenre( ) { val key = Key(this) - data class Key(val value: RawGenre) { + /** + * Allows [RawGenre]s to be compared by "fundamental" information that is unlikely to change on + * an item-by-item + */ + data class Key(private val inner: RawGenre) { // Cache the hashCode for HashMap efficiency. - private val hashCode = value.name?.lowercase().hashCode() + private val hashCode = inner.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 @@ -219,10 +230,28 @@ data class RawGenre( override fun equals(other: Any?) = other is Key && when { - value.name != null && other.value.name != null -> - value.name.equals(other.value.name, true) - value.name == null && other.value.name == null -> true + inner.name != null && other.inner.name != null -> + inner.name.equals(other.inner.name, true) + inner.name == null && other.inner.name == null -> true else -> false } } } + +/** + * Represents grouped music information and the prioritized raw information to eventually derive a + * [Music] implementation instance from. + * + * @param raw The current [PrioritizedRaw] that will be used for the finalized music information. + * @param music The child [Music] instances of the music information to be created. + */ +data class Grouping(var raw: PrioritizedRaw, val music: MutableSet) + +/** + * Represents a [RawAlbum], [RawArtist], or [RawGenre] specifically chosen to create a [Music] + * instance from due to it being the most likely source of truth. + * + * @param inner The raw music instance that will be used. + * @param src The [Music] instance that the raw information was derived from. + */ +data class PrioritizedRaw(val inner: R, val src: M) diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index 838f8f5d5..03f3a33f6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -203,8 +203,8 @@ private data class IntelligentKnownName(override val raw: String, override val s // Separate each token into their numeric and lexicographic counterparts. if (token.first().isDigit()) { // The digit string comparison breaks with preceding zero digits, remove those - // TODO: Handle zero digits in other languages - val digits = token.trimStart('0').ifEmpty { token } + val digits = + token.trimStart { Character.getNumericValue(it) == 0 }.ifEmpty { token } // Other languages have other types of digit strings, still use collation keys collationKey = COLLATOR.getCollationKey(digits) type = SortToken.Type.NUMERIC diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index b709cb558..5d9eebcbc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -30,6 +30,7 @@ import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.nonZeroOrNull /** * An processing abstraction over the [MetadataRetriever] and [TextTags] workflow that operates on @@ -140,15 +141,16 @@ private class TagWorkerImpl( textFrames["TALB"]?.let { rawSong.albumName = it.first() } textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() } (textFrames["TXXX:musicbrainz album type"] - ?: textFrames["TXXX:releasetype"] ?: textFrames["GRP1"]) + ?: textFrames["TXXX:releasetype"] ?: + // This is a non-standard iTunes extension + textFrames["GRP1"]) ?.let { rawSong.releaseTypes = it } // Artist textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it } (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it } - (textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { - rawSong.artistSortNames = it - } + (textFrames["TXXX:artistssort"] ?: textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"]) + ?.let { rawSong.artistSortNames = it } // Album artist textFrames["TXXX:musicbrainz album artist id"]?.let { @@ -157,16 +159,18 @@ private class TagWorkerImpl( (textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { rawSong.albumArtistNames = it } - (textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let { - rawSong.albumArtistSortNames = it - } + (textFrames["TXXX:albumartistssort"] + ?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartistsort"] + // This is a non-standard iTunes extension + ?: textFrames["TSO2"]) + ?.let { rawSong.albumArtistSortNames = it } // Genre textFrames["TCON"]?.let { rawSong.genreNames = it } // Compilation Flag - (textFrames["TCMP"] - ?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"]) + (textFrames["TCMP"] // This is a non-standard itunes extension + ?: textFrames["TXXX:compilation"] ?: textFrames["TXXX:itunescompilation"]) ?.let { // Ignore invalid instances of this tag if (it.size != 1 || it[0] != "1") return@let @@ -175,6 +179,14 @@ private class TagWorkerImpl( rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS } rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES } } + + // ReplayGain information + textFrames["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment()?.let { + rawSong.replayGainTrackAdjustment = it + } + textFrames["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment()?.let { + rawSong.replayGainAlbumAdjustment = it + } } private fun parseId3v23Date(textFrames: Map>): Date? { @@ -249,14 +261,18 @@ private class TagWorkerImpl( // Artist comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } - (comments["artists_sort"] ?: comments["artistsort"])?.let { rawSong.artistSortNames = it } + (comments["artistssort"] ?: comments["artists_sort"] ?: comments["artistsort"])?.let { + rawSong.artistSortNames = it + } // Album artist comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it } - (comments["albumartists"] ?: comments["albumartist"])?.let { rawSong.albumArtistNames = it } - (comments["albumartists_sort"] ?: comments["albumartistsort"])?.let { - rawSong.albumArtistSortNames = it + (comments["albumartists"] ?: comments["album_artists"] ?: comments["albumartist"])?.let { + rawSong.albumArtistNames = it } + (comments["albumartistssort"] + ?: comments["albumartists_sort"] ?: comments["albumartistsort"]) + ?.let { rawSong.albumArtistSortNames = it } // Genre comments["genre"]?.let { rawSong.genreNames = it } @@ -270,10 +286,38 @@ private class TagWorkerImpl( rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS } rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES } } + + // ReplayGain information + // Most ReplayGain tags are formatted as a simple decibel adjustment in a custom + // replaygain_*_gain tag, but opus has it's own "r128_*_gain" ReplayGain specification, + // which requires dividing the adjustment by 256 to get the gain. This is used alongside + // the base adjustment intrinsic to the format to create the normalized adjustment. This is + // normally the only tag used for opus files, but some software still writes replay gain + // tags anyway. + (comments["r128_track_gain"]?.parseReplayGainAdjustment()?.div(256) + ?: comments["replaygain_track_gain"]?.parseReplayGainAdjustment()) + ?.let { rawSong.replayGainTrackAdjustment = it } + (comments["r128_album_gain"]?.parseReplayGainAdjustment()?.div(256) + ?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment()) + ?.let { rawSong.replayGainAlbumAdjustment = it } } + /** + * Parse a ReplayGain adjustment into a float value. + * + * @return A parsed adjustment float, or null if the adjustment had invalid formatting. + */ + private fun List.parseReplayGainAdjustment() = + first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull() + private companion object { val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists") val COMPILATION_RELEASE_TYPES = listOf("compilation") + + /** + * Matches non-float information from ReplayGain adjustments. Derived from vanilla music: + * https://github.com/vanilla-music/vanilla + */ + val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX by lazy { Regex("[^\\d.-]") } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt index cecd572ba..a3d916b69 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt @@ -28,6 +28,8 @@ import androidx.media3.extractor.metadata.vorbis.VorbisComment * * @param metadata The [Metadata] to wrap. * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Merge with TagWorker */ class TextTags(metadata: Metadata) { private val _id3v2 = mutableMapOf>() 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 412b14fa4..bb1dda066 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 @@ -20,7 +20,6 @@ package org.oxycblt.auxio.music.user import java.lang.Exception import javax.inject.Inject -import kotlinx.coroutines.channels.Channel import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings @@ -38,10 +37,12 @@ import org.oxycblt.auxio.util.logE * generally not expected to create this yourself, and instead rely on MusicRepository. * * @author Alexander Capehart + * + * TODO: Communicate errors */ interface UserLibrary { /** The current user-defined playlists. */ - val playlists: List + val playlists: Collection /** * Find a [Playlist] instance corresponding to the given [Music.UID]. @@ -62,14 +63,25 @@ interface UserLibrary { /** Constructs a [UserLibrary] implementation in an asynchronous manner. */ interface Factory { /** - * Create a new [UserLibrary]. + * Read all [RawPlaylist] information from the database, which can be transformed into a + * [UserLibrary] later. * - * @param deviceLibraryChannel Asynchronously populated [DeviceLibrary] that can be obtained - * later. This allows database information to be read before the actual instance is - * constructed. - * @return A new [MutableUserLibrary] with the required implementation. + * @return A list of [RawPlaylist]s. */ - suspend fun read(deviceLibraryChannel: Channel): MutableUserLibrary + suspend fun query(): List + + /** + * Create a new [UserLibrary] from read [RawPlaylist] instances and a precursor + * [DeviceLibrary]. + * + * @param rawPlaylists The [RawPlaylist]s to use. + * @param deviceLibrary The [DeviceLibrary] to use. + * @return The new [UserLibrary] instance. + */ + suspend fun create( + rawPlaylists: List, + deviceLibrary: DeviceLibrary + ): MutableUserLibrary } } @@ -85,55 +97,63 @@ interface MutableUserLibrary : UserLibrary { * * @param name The name of the [Playlist]. * @param songs The songs to place in the [Playlist]. + * @return The new [Playlist] instance, or null if one could not be created. */ - suspend fun createPlaylist(name: String, songs: List) + suspend fun createPlaylist(name: String, songs: List): Playlist? /** * Rename a [Playlist]. * * @param playlist The [Playlist] to rename. * @param name The name of the new [Playlist]. + * @return True if the [Playlist] was successfully renamed, false otherwise. */ - suspend fun renamePlaylist(playlist: Playlist, name: String) + suspend fun renamePlaylist(playlist: Playlist, name: String): Boolean /** * Delete a [Playlist]. * * @param playlist The playlist to delete. + * @return True if the [Playlist] was successfully deleted, false otherwise. */ - suspend fun deletePlaylist(playlist: Playlist) + suspend fun deletePlaylist(playlist: Playlist): Boolean /** * Add [Song]s to a [Playlist]. * * @param playlist The [Playlist] to add to. Must currently exist. + * @param songs The [Song]s to add to the [Playlist]. + * @return True if the [Song]s were successfully added, false otherwise. */ - suspend fun addToPlaylist(playlist: Playlist, songs: List) + suspend fun addToPlaylist(playlist: Playlist, songs: List): Boolean /** * Update the [Song]s of a [Playlist]. * * @param playlist The [Playlist] to update. * @param songs The new [Song]s to be contained in the [Playlist]. + * @return True if the [Playlist] was successfully updated, false otherwise. */ - suspend fun rewritePlaylist(playlist: Playlist, songs: List) + suspend fun rewritePlaylist(playlist: Playlist, songs: List): Boolean } class UserLibraryFactoryImpl @Inject constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : UserLibrary.Factory { - override suspend fun read(deviceLibraryChannel: Channel): MutableUserLibrary { - // While were waiting for the library, read our playlists out. - val rawPlaylists = - try { - playlistDao.readRawPlaylists() - } catch (e: Exception) { - logE("Unable to read playlists: $e") - return UserLibraryImpl(playlistDao, mutableMapOf(), musicSettings) - } + override suspend fun query() = + try { + playlistDao.readRawPlaylists() + } catch (e: Exception) { + logE("Unable to read playlists: $e") + listOf() + } + + override suspend fun create( + rawPlaylists: List, + deviceLibrary: DeviceLibrary + ): MutableUserLibrary { logD("Successfully read ${rawPlaylists.size} playlists") - val deviceLibrary = deviceLibraryChannel.receive() // Convert the database playlist information to actual usable playlists. val playlistMap = mutableMapOf() for (rawPlaylist in rawPlaylists) { @@ -153,89 +173,106 @@ private class UserLibraryImpl( override fun equals(other: Any?) = other is UserLibraryImpl && other.playlistMap == playlistMap override fun toString() = "UserLibrary(playlists=${playlists.size})" - override val playlists: List - get() = playlistMap.values.toList() + override val playlists: Collection + get() = playlistMap.values.toSet() override fun findPlaylist(uid: Music.UID) = playlistMap[uid] override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } - override suspend fun createPlaylist(name: String, songs: List) { - // TODO: Use synchronized with value access too + override suspend fun createPlaylist(name: String, songs: List): Playlist? { val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } val rawPlaylist = RawPlaylist( PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw), playlistImpl.songs.map { PlaylistSong(it.uid) }) - try { + + return try { playlistDao.insertPlaylist(rawPlaylist) logD("Successfully created playlist $name with ${songs.size} songs") + playlistImpl } catch (e: Exception) { logE("Unable to create playlist $name with ${songs.size} songs") logE(e.stackTraceToString()) synchronized(this) { playlistMap.remove(playlistImpl.uid) } - return + null } } - override suspend fun renamePlaylist(playlist: Playlist, name: String) { + override suspend fun renamePlaylist(playlist: Playlist, name: String): Boolean { val playlistImpl = - requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } - synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) } - try { + synchronized(this) { + requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } + .also { playlistMap[it.uid] = it.edit(name, musicSettings) } + } + + return try { playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name)) logD("Successfully renamed $playlist to $name") + true } catch (e: Exception) { logE("Unable to rename $playlist to $name: $e") logE(e.stackTraceToString()) synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } - return + false } } - override suspend fun deletePlaylist(playlist: Playlist) { + override suspend fun deletePlaylist(playlist: Playlist): Boolean { val playlistImpl = - requireNotNull(playlistMap[playlist.uid]) { "Cannot remove invalid playlist" } - synchronized(this) { playlistMap.remove(playlistImpl.uid) } - try { + synchronized(this) { + requireNotNull(playlistMap[playlist.uid]) { "Cannot remove invalid playlist" } + .also { playlistMap.remove(it.uid) } + } + + return try { playlistDao.deletePlaylist(playlist.uid) logD("Successfully deleted $playlist") + true } catch (e: Exception) { logE("Unable to delete $playlist: $e") logE(e.stackTraceToString()) synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } - return + false } } - override suspend fun addToPlaylist(playlist: Playlist, songs: List) { + override suspend fun addToPlaylist(playlist: Playlist, songs: List): Boolean { val playlistImpl = - requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } - synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } } - try { + synchronized(this) { + requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } + .also { playlistMap[it.uid] = it.edit { addAll(songs) } } + } + + return try { playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) logD("Successfully added ${songs.size} songs to $playlist") + true } catch (e: Exception) { logE("Unable to add ${songs.size} songs to $playlist: $e") logE(e.stackTraceToString()) synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } - return + false } } - override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { + override suspend fun rewritePlaylist(playlist: Playlist, songs: List): Boolean { val playlistImpl = - requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" } - synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(songs) } - try { + synchronized(this) { + requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" } + .also { playlistMap[it.uid] = it.edit(songs) } + } + + return try { playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) logD("Successfully rewrote $playlist with ${songs.size} songs") + true } catch (e: Exception) { logE("Unable to rewrite $playlist with ${songs.size} songs: $e") logE(e.stackTraceToString()) synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } - return + false } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt index 10e55c5bd..10a42edb1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt @@ -43,7 +43,5 @@ class UserRoomModule { Room.databaseBuilder( context.applicationContext, UserMusicDatabase::class.java, "user_music.db") .fallbackToDestructiveMigration() - .fallbackToDestructiveMigrationFrom(0) - .fallbackToDestructiveMigrationOnDowngrade() .build() } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt index 93e387068..466b56812 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt @@ -27,6 +27,7 @@ import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.playback.state.RepeatMode @@ -37,7 +38,7 @@ import org.oxycblt.auxio.playback.state.RepeatMode */ @Database( entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class], - version = 27, + version = 32, exportSchema = false) @TypeConverters(Music.UID.TypeConverters::class) abstract class PersistenceDatabase : RoomDatabase() { @@ -54,6 +55,16 @@ abstract class PersistenceDatabase : RoomDatabase() { * @return A [QueueDao] providing control of the database's queue tables. */ abstract fun queueDao(): QueueDao + + companion object { + val MIGRATION_27_32 = + Migration(27, 32) { + // Switched from custom names to just letting room pick the names + it.execSQL("ALTER TABLE playback_state RENAME TO PlaybackState") + it.execSQL("ALTER TABLE queue_heap RENAME TO QueueHeapItem") + it.execSQL("ALTER TABLE queue_mapping RENAME TO QueueMappingItem") + } + } } /** @@ -68,11 +79,10 @@ interface PlaybackStateDao { * * @return The previously persisted [PlaybackState], or null if one was not present. */ - @Query("SELECT * FROM ${PlaybackState.TABLE_NAME} WHERE id = 0") - suspend fun getState(): PlaybackState? + @Query("SELECT * FROM PlaybackState WHERE id = 0") suspend fun getState(): PlaybackState? /** Delete any previously persisted [PlaybackState]s. */ - @Query("DELETE FROM ${PlaybackState.TABLE_NAME}") suspend fun nukeState() + @Query("DELETE FROM PlaybackState") suspend fun nukeState() /** * Insert a new [PlaybackState] into the database. @@ -94,21 +104,20 @@ interface QueueDao { * * @return A list of persisted [QueueHeapItem]s wrapping each heap item. */ - @Query("SELECT * FROM ${QueueHeapItem.TABLE_NAME}") suspend fun getHeap(): List + @Query("SELECT * FROM QueueHeapItem") suspend fun getHeap(): List /** * Get the previously persisted queue mapping. * * @return A list of persisted [QueueMappingItem]s wrapping each heap item. */ - @Query("SELECT * FROM ${QueueMappingItem.TABLE_NAME}") - suspend fun getMapping(): List + @Query("SELECT * FROM QueueMappingItem") suspend fun getMapping(): List /** Delete any previously persisted queue heap entries. */ - @Query("DELETE FROM ${QueueHeapItem.TABLE_NAME}") suspend fun nukeHeap() + @Query("DELETE FROM QueueHeapItem") suspend fun nukeHeap() /** Delete any previously persisted queue mapping entries. */ - @Query("DELETE FROM ${QueueMappingItem.TABLE_NAME}") suspend fun nukeMapping() + @Query("DELETE FROM QueueMappingItem") suspend fun nukeMapping() /** * Insert new heap entries into the database. @@ -128,7 +137,7 @@ interface QueueDao { // TODO: Figure out how to get RepeatMode to map to an int instead of a string // TODO: Use intrinsic table names rather than custom names -@Entity(tableName = PlaybackState.TABLE_NAME) +@Entity data class PlaybackState( @PrimaryKey val id: Int, val index: Int, @@ -136,26 +145,9 @@ data class PlaybackState( val repeatMode: RepeatMode, val songUid: Music.UID, val parentUid: Music.UID? -) { - companion object { - const val TABLE_NAME = "playback_state" - } -} +) -@Entity(tableName = QueueHeapItem.TABLE_NAME) -data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) { - companion object { - const val TABLE_NAME = "queue_heap" - } -} +@Entity data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) -@Entity(tableName = QueueMappingItem.TABLE_NAME) -data class QueueMappingItem( - @PrimaryKey val id: Int, - val orderedIndex: Int, - val shuffledIndex: Int -) { - companion object { - const val TABLE_NAME = "queue_mapping" - } -} +@Entity +data class QueueMappingItem(@PrimaryKey val id: Int, val orderedIndex: Int, val shuffledIndex: Int) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceModule.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceModule.kt index 76af5369b..fd82e75d4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceModule.kt @@ -45,8 +45,7 @@ class PersistenceRoomModule { PersistenceDatabase::class.java, "playback_persistence.db") .fallbackToDestructiveMigration() - .fallbackToDestructiveMigrationFrom(1) - .fallbackToDestructiveMigrationOnDowngrade() + .addMigrations(PersistenceDatabase.MIGRATION_27_32) .build() @Provides fun playbackStateDao(database: PersistenceDatabase) = database.playbackStateDao() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt index c8fc134e7..e42e6ff61 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt @@ -81,7 +81,7 @@ class PlayFromArtistDialog : override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { super.onDestroyBinding(binding) - choiceAdapter + binding.choiceRecycler.adapter = null } override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt index 1f2693a10..6811e3510 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt @@ -81,7 +81,7 @@ class PlayFromGenreDialog : override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { super.onDestroyBinding(binding) - choiceAdapter + binding.choiceRecycler.adapter = null } override fun onClick(item: Genre, viewHolder: RecyclerView.ViewHolder) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt index 0b1855a50..1495fd6fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt @@ -50,6 +50,15 @@ enum class ReplayGainMode { } } +/** + * Represents a ReplayGain adjustment to apply during song playback. + * + * @param track The track-specific adjustment that should be applied. Null if not available. + * @param album A more general album-specific adjustment that should be applied. Null if not + * available. + */ +data class ReplayGainAdjustment(val track: Float?, val album: Float?) + /** * The current ReplayGain pre-amp configuration. * diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index ab86651e0..5c039ab8c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -21,15 +21,16 @@ package org.oxycblt.auxio.playback.replaygain import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.Player -import androidx.media3.common.Tracks import androidx.media3.common.audio.AudioProcessor import androidx.media3.exoplayer.audio.BaseAudioProcessor import java.nio.ByteBuffer import javax.inject.Inject import kotlin.math.pow import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.metadata.TextTags +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD @@ -48,9 +49,7 @@ class ReplayGainAudioProcessor constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings -) : BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener { - private var lastFormat: Format? = null - +) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { private var volume = 1f set(value) { field = value @@ -58,51 +57,38 @@ constructor( flush() } - /** - * Add this instance to the components required for it to function correctly. - * - * @param player The [Player] to attach to. Should already have this instance as an audio - * processor. - */ - fun addToListeners(player: Player) { - player.addListener(this) + init { + playbackManager.addListener(this) playbackSettings.registerListener(this) } - /** - * Remove this instance from the components required for it to function correctly. - * - * @param player The [Player] to detach from. Should already have this instance as an audio - * processor. - */ - fun releaseFromListeners(player: Player) { - player.removeListener(this) + /** Remove this instance from the components required for it to function correctly. */ + fun release() { + playbackManager.removeListener(this) playbackSettings.unregisterListener(this) } // --- OVERRIDES --- - override fun onTracksChanged(tracks: Tracks) { - super.onTracksChanged(tracks) - // Try to find the currently playing track so we can update the ReplayGain adjustment - // based on it. - for (group in tracks.groups) { - if (group.isSelected) { - for (i in 0 until group.length) { - if (group.isTrackSelected(i)) { - applyReplayGain(group.getTrackFormat(i)) - return - } - } - } + override fun onIndexMoved(queue: Queue) { + logD("Index moved, updating current song") + applyReplayGain(queue.currentSong) + } + + override fun onQueueChanged(queue: Queue, change: Queue.Change) { + // Other types of queue changes preserve the current song. + if (change.type == Queue.Change.Type.SONG) { + applyReplayGain(queue.currentSong) } - // Nothing selected, apply nothing - applyReplayGain(null) + } + override fun onNewPlayback(queue: Queue, parent: MusicParent?) { + logD("New playback started, updating playback information") + applyReplayGain(queue.currentSong) } override fun onReplayGainSettingsChanged() { // ReplayGain config changed, we need to set it up again. - applyReplayGain(lastFormat) + applyReplayGain(playbackManager.queue.currentSong) } // --- REPLAYGAIN PARSING --- @@ -110,115 +96,63 @@ constructor( /** * Updates the volume adjustment based on the given [Format]. * - * @param format The [Format] of the currently playing track, or null if nothing is playing. + * @param song The [Format] of the currently playing track, or null if nothing is playing. */ - private fun applyReplayGain(format: Format?) { - lastFormat = format - val gain = parseReplayGain(format ?: return) + private fun applyReplayGain(song: Song?) { + if (song == null) { + logD("Nothing playing, disabling adjustment") + volume = 1f + return + } + + logD("Applying ReplayGain adjustment for $song") + + val gain = song.replayGainAdjustment val preAmp = playbackSettings.replayGainPreAmp - val adjust = - if (gain != null) { - logD("Found ReplayGain adjustment $gain") - // ReplayGain is configurable, so determine what to do based off of the mode. - val useAlbumGain = - when (playbackSettings.replayGainMode) { - // User wants track gain to be preferred. Default to album gain only if - // there is no track gain. - ReplayGainMode.TRACK -> { - logD("Using track strategy") - gain.track == 0f - } - // User wants album gain to be preferred. Default to track gain only if - // here is no album gain. - ReplayGainMode.ALBUM -> { - logD("Using album strategy") - gain.album != 0f - } - // User wants album gain to be used when in an album, track gain otherwise. - ReplayGainMode.DYNAMIC -> { - logD("Using dynamic strategy") - playbackManager.parent is Album && - playbackManager.queue.currentSong?.album == playbackManager.parent - } + // ReplayGain is configurable, so determine what to do based off of the mode. + val resolvedAdjustment = + when (playbackSettings.replayGainMode) { + // User wants track gain to be preferred. Default to album gain only if + // there is no track gain. + ReplayGainMode.TRACK -> { + logD("Using track strategy") + gain.track ?: gain.album + } + // User wants album gain to be preferred. Default to track gain only if + // here is no album gain. + ReplayGainMode.ALBUM -> { + logD("Using album strategy") + gain.album ?: gain.track + } + // User wants album gain to be used when in an album, track gain otherwise. + ReplayGainMode.DYNAMIC -> { + logD("Using dynamic strategy") + gain.album?.takeIf { + playbackManager.parent is Album && + playbackManager.queue.currentSong?.album == playbackManager.parent } + ?: gain.track + } + } - val resolvedGain = - if (useAlbumGain) { - logD("Using album gain") - gain.album - } else { - logD("Using track gain") - gain.track - } - - // Apply the adjustment specified when there is ReplayGain tags. - resolvedGain + preAmp.with + val amplifiedAdjustment = + if (resolvedAdjustment != null) { + // Successfully resolved an adjustment, apply the corresponding pre-amp + logD("Applying with pre-amp") + resolvedAdjustment + preAmp.with } else { - // No ReplayGain tags existed, or no tags were parsable, or there was no metadata - // in the first place. Return the gain to use when there is no ReplayGain value. - logD("No ReplayGain tags present") + // No adjustment found, use the corresponding user-defined pre-amp + logD("Applying without pre-amp") preAmp.without } - logD("Applying ReplayGain adjustment ${adjust}db") + logD("Applying ReplayGain adjustment ${amplifiedAdjustment}db") // Final adjustment along the volume curve. - volume = 10f.pow(adjust / 20f) + volume = 10f.pow(amplifiedAdjustment / 20f) } - /** - * Parse ReplayGain information from the given [Format]. - * - * @param format The [Format] to parse. - * @return A [Adjustment] adjustment, or null if there were no valid adjustments. - */ - private fun parseReplayGain(format: Format): Adjustment? { - val textTags = TextTags(format.metadata ?: return null) - var trackGain = 0f - var albumGain = 0f - - // Most ReplayGain tags are formatted as a simple decibel adjustment in a custom - // replaygain_*_gain tag. - textTags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"] - ?.run { first().parseReplayGainAdjustment() } - ?.let { trackGain = it } - textTags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"] - ?.run { first().parseReplayGainAdjustment() } - ?.let { albumGain = it } - textTags.vorbis[TAG_RG_ALBUM_GAIN] - ?.run { first().parseReplayGainAdjustment() } - ?.let { trackGain = it } - textTags.vorbis[TAG_RG_TRACK_GAIN] - ?.run { first().parseReplayGainAdjustment() } - ?.let { albumGain = it } - - // Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the - // adjustment by 256 to get the gain. This is used alongside the base adjustment - // intrinsic to the format to create the normalized adjustment. This is normally the only - // tag used for opus files, but some software still writes replay gain tags anyway. - textTags.vorbis[TAG_R128_TRACK_GAIN] - ?.run { first().parseReplayGainAdjustment() } - ?.let { trackGain = it / 256f } - textTags.vorbis[TAG_R128_ALBUM_GAIN] - ?.run { first().parseReplayGainAdjustment() } - ?.let { albumGain = it / 256f } - - return if (trackGain != 0f || albumGain != 0f) { - Adjustment(trackGain, albumGain) - } else { - null - } - } - - /** - * Parse a ReplayGain adjustment into a float value. - * - * @return A parsed adjustment float, or null if the adjustment had invalid formatting. - */ - private fun String.parseReplayGainAdjustment() = - replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull() - // --- AUDIO PROCESSOR IMPLEMENTATION --- override fun onConfigure( @@ -284,25 +218,4 @@ constructor( put(short.toByte()) put(short.toInt().shr(8).toByte()) } - - /** - * The resolved ReplayGain adjustment for a file. - * - * @param track The track adjustment (in dB), or 0 if it is not present. - * @param album The album adjustment (in dB), or 0 if it is not present. - */ - private data class Adjustment(val track: Float, val album: Float) - - private companion object { - const val TAG_RG_TRACK_GAIN = "replaygain_track_gain" - const val TAG_RG_ALBUM_GAIN = "replaygain_album_gain" - const val TAG_R128_TRACK_GAIN = "r128_track_gain" - const val TAG_R128_ALBUM_GAIN = "r128_album_gain" - - /** - * Matches non-float information from ReplayGain adjustments. Derived from vanilla music: - * https://github.com/vanilla-music/vanilla - */ - val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX by lazy { Regex("[^\\d.-]") } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index 9d273ac98..9acbe82d0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -367,7 +367,7 @@ constructor( .setSubtitle(song.artists.resolveNames(context)) // Since we usually have to load many songs into the queue, use the // MediaStore URI instead of loading a bitmap. - .setIconUri(song.album.coverUri) + .setIconUri(song.album.coverUri.mediaStore) .setMediaUri(song.uri) .build() // Store the item index so we can then use the analogous index in the diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 848d47b4d..26629030b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -144,7 +144,6 @@ class PlaybackService : true) .build() .also { it.addListener(this) } - replayGainProcessor.addToListeners(player) foregroundManager = ForegroundManager(this) // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. @@ -196,7 +195,7 @@ class PlaybackService : widgetComponent.release() mediaSessionComponent.release() - replayGainProcessor.releaseFromListeners(player) + replayGainProcessor.release() player.release() if (openAudioEffectSession) { // Make sure to close the audio session when we release the player. diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index a4471ae5e..24c5389fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -58,11 +58,11 @@ interface SearchEngine { * @param playlists A list of [Playlist], null if empty. */ data class Items( - val songs: List?, - val albums: List?, - val artists: List?, - val genres: List?, - val playlists: List? + val songs: Collection?, + val albums: Collection?, + val artists: Collection?, + val genres: Collection?, + val playlists: Collection? ) } @@ -90,7 +90,7 @@ class SearchEngineImpl @Inject constructor(@ApplicationContext private val conte * initially. This can be used to compare against additional attributes to improve search * result quality. */ - private inline fun List.searchListImpl( + private inline fun Collection.searchListImpl( query: String, fallback: (String, T) -> Boolean = { _, _ -> false } ) = diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index eb24d8093..d06c0ca37 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -286,7 +286,7 @@ fun Context.share(parent: MusicParent) = share(parent.songs) * * @param songs The [Song]s to share. */ -fun Context.share(songs: List) { +fun Context.share(songs: Collection) { if (songs.isEmpty()) return logD("Showing sharesheet for ${songs.size} songs") val builder = ShareCompat.IntentBuilder(this) 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 51835d68c..3ad2f8eb1 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -50,6 +50,13 @@ fun Int.nonZeroOrNull() = if (this > 0) this else null */ fun Long.nonZeroOrNull() = if (this > 0) this else null +/** + * Aliases a check to ensure that the given number is non-zero. + * + * @return The same number if it's non-zero, null otherwise. + */ +fun Float.nonZeroOrNull() = if (this > 0) this else null + /** * Aliases a check to ensure a given value is in a specified range. * diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e70e04dfd..03ca85350 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -169,7 +169,7 @@ Matroska-Audio Advanced Audio Coding (AAC) Free Lossless Audio Codec (FLAC) - %d kbps + %d kB/s %d Hz Lade deine Musikbibliothek… (%1$d/%2$d) Mischen diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 85ff6f8e5..62053b8a5 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -22,7 +22,7 @@ Tyylilaji Tyylilajit Soittolista - Mix + DJ-mix Live Soittolistat Etsi @@ -164,7 +164,7 @@ Remix-albumi Remix-EP Remix-single - Remix-kokoelmat + Remix-kokoelma Ladataan musiikkikirjastoa… Versio Väriteema @@ -182,7 +182,7 @@ Käytä mustaa teemaa Pyöristetty tila Elokuvamusiikit - Mixaukset + DJ-mixaukset Auxio tarvitsee luvan lukea musiikkikirjastoa Asetukset Järjestä @@ -243,4 +243,28 @@ Palauta aiemmin tallennettu toiston tila (jos olemassa) Musiikkia ei ladata valitsemistasi kansioista. Suosi albumia, jos sellaista toistetaan + Uusi soittolista + Soittolista %d + Lisää soittolistaan + Ei kappaleita + Ota käyttöön pyöristetyt reunat käyttöliittymän lisäelementeissä (vaatii albumikansien olevan pyöristettyjä) + Lataa musiikkikirjasto uudelleen sen muuttuessa (vaatii pysyvän ilmoituksen) + Kauttaviiva (/) + Poista + Nimeä uudelleen + Nimeä soittolista uudelleen + Poistetaanko soittolista\? + Muokataan %s + Muokkaa + Poista tämä kappale + Soittolista luotu + Soittolista nimetty uudelleen + Soittolista poistettu + Lisätty soittolistaan + Jaa + Esiintyy + Näytä ja hallinnoi musiikin toistoa + Siirrä tämä kappale + Ei levyä + Poistetaanko %s\? Tätä toimenpidettä ei voi perua. \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 353726d0a..55adf537f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -41,7 +41,7 @@ Pas de musique trouvée Morceau %d - Lecture/Pause + Lecture ou pause Recherche dans votre bibliothèque… @@ -50,26 +50,26 @@ Violet Indigo Bleu - Bleu Clair + Bleu foncé Bleu Vert Vert - Vert Clair + Vert foncé Vert Citron Jaune Orange Brun Gris - Titres chargés: %d + Titres chargés : %d - %s Titre - %s Titres - %s Titres + %d titre + %d titres + %d titres - %s Album - %s Albums - %s Albums + %d album + %d albums + %d albums Format État sauvegardé @@ -92,7 +92,7 @@ Affichage Onglets de la bibliothèque Un lecteur de musique simple et rationnel pour Android. - Chargement de musique + Chargement de la musique Afficher et contrôler la lecture de la musique Chargement de votre bibliothèque musicale… Nom @@ -106,7 +106,7 @@ Voir les propriétés Propriétés de la chanson EP live - EP de remixes + EP de remix Single live Single remixé Compilations @@ -114,7 +114,7 @@ Live Chargement de la musique Suivre la librairie musicale - EPs + EP EP Singles Single @@ -125,7 +125,7 @@ Remix Date d\'ajout Album live - Album de remixes + Album de remix Genre Égaliseur Lecture aléatoire de tous les titres @@ -140,9 +140,9 @@ Supprimer le dossier Artiste inconnu Compilation en direct - Compilations de remix - Mixes - Mix + Compilation de remix + Mix DJ + Mix DJ Ce dossier n\'est pas pris en charge Réinitialiser Ogg audio @@ -194,7 +194,7 @@ Virgule (,) Point-virgule (;) Ignorer les fichiers audio qui ne sont pas de la musique, tels que les podcasts - Avertissement: L\'utilisation de ce paramètre peut entraîner l\'interprétation incorrecte de certaines balises comme ayant plusieurs valeurs. Vous pouvez résoudre ce problème en préfixant les caractères de séparation indésirables avec une barre oblique inverse (\\). + Avertissement : L\'utilisation de ce paramètre peut entraîner l\'interprétation incorrecte de certaines balises comme ayant plusieurs valeurs. Vous pouvez résoudre ce problème en préfixant les caractères de séparation indésirables avec une barre oblique inverse (\\). Exclure non-musique Lire depuis l\'album Barre oblique (/) @@ -236,4 +236,65 @@ Recharger la bibliothèque musicale en utilisant si possible les étiquettes en cache Mode Exclure + Nouvelle liste de lecture + Passer à la chanson suivante + Activer ou désactiver la lecture aléatoire + %d Hz + Passer à la dernière chanson + Ajouter à la liste de lecture + Créer une nouvelle liste de lecture + Audio Matroska + Artistes chargés&nbsp;: %d + Rembobiner avant de revenir en arrière + Image d\'artiste pour %s + Aucune piste + Aucune musique en cours de lecture + Supprimer + Pause quand une chanson se répète + Déplacer cet onglet + Renommer + Impossible d\'effacer l\'état + Modifier le mode de répétition + Albums chargés&nbsp;: %d + Durée totale&nbsp;: %s + Effacer la requête de recherche + Image de la liste de lecture pour %s + Disque %d + Chargement de votre bibliothèque musicale… (%1$d/%2$d) + + %d artiste + %d artistes + %d artistes + + Modifier + Pause en cas de répétition + Rembobiner avant de passer à la chanson précédente + Arrêter la lecture + Liste de lecture créée + Liste de lecture renommée + Liste de lecture supprimée + Ajouté à la liste de lecture + %d ko/s + +%.1f dB + -%.1f dB + Supprimer %s&nbsp;\? Cette opération ne peut pas être annulée. + Avertissement&nbsp;: Le fait de régler le préamplificateur sur une valeur positive élevée peut entraîner l\'apparition des distortions sur certaines pistes audio. + Liste de lecture %d + Apparaît sur + Renommer la liste de lecture + Supprimer la liste de lecture&nbsp;\? + Partager + Retirer cette chanson + Déplacer cette chanson + Ouvrir la file d\'attente + Impossible de sauvegarder l\'état + Aucune chanson + Modification de %s + Genres chargés&nbsp;: %d + Image de genre pour %s + Codec audio gratuit sans perte (FLAC) + %d sélectionnés + Codage audio avancé (AAC) + Aucun disque + %1$s, %2$s \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 157e025f0..2228fd6eb 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -59,4 +59,132 @@ गाने लोड हो रहे है गाने लोड हो रहे है एंड्रॉयड के लिए एक सीधा साधा, विवेकशील गाने बजाने वाला ऐप। + नई प्लेलिस्ट + अगला चलाएं + फ़ाइल का नाम + लायब्रेरी टैब्स + एल्बम से चलाएं + सामग्री + %d चयनित + प्रारूप + प्लेलिस्ट में जोड़ें + मुख्य पथ + बिट-रेट + रद्द करें + सहेजें + एप्लिकेशन की थीम और रंग बदलें + प्लेलिस्ट में जोड़ा गया + अगले पर जाएं + रिपीट मोड + संगीत और छवियों को लोड करने के तरीके को नियंत्रित करें + गैर-संगीत को बाहर रखें + स्वचालित पुनः लोडिंग + +%.1f dB + प्लेलिस्ट %d + %1$s, %2$s + गतिशील + लुक और फील + अतिरिक्त UI तत्वों पर गोल कोनों को सक्षम करें (एल्बम कवर को गोल करने की आवश्यकता है) + दिखाए गए आइटम से चलाएँ + लाइब्रेरी से चलाते समय + संगीत लाइब्रेरी को फिर से लोड करें जब भी यह बदलता है (स्थाई नोटीफिकेशन की आवश्यकता होती है) + ऑडियो फ़ाइलों को अनदेखा करें जो संगीत नहीं हैं, जैसे कि पॉडकास्ट + लाइव संकलन + रीमिक्स संकलन + संस्करण + सभी शफल करें + प्लेलिस्ट + प्लेलिस्टें + गोल मोड + सभी गीतों से चलाएं + %s हटाएँ\? इसे पूर्ववत नहीं किया जा सकता। + लोड किए गए गाने: %d + अवरोही + चयनित चलाएँ + फेरबदल का चयन किया गया + स्थिति साफ की गई + स्थिति सहेजी गई + लायब्रेरी टैब की दृश्यता और क्रम बदलें + संगीत + UI नियंत्रण और व्यवहार अनुकूलित करें + कलाकार लोड किए गए: %d + कस्टम प्लेबैक बार एक्शन + आइटम विवरण से चलाते समय + लाइव एल्बम + रीमिक्स एल्बम + लाइव EP + रीमिक्स EP + लाइव सिंगल + रीमिक्स सिंगल + संकलन + प्लेलिस्ट हटाएँ\? + ब्लैक थीम + संगीत प्लेबैक देखें और नियंत्रित करें + रीसेट + विकी + लाइब्रेरी के आंकड़े + कुल अवधि: %s + इक्वलाइज़र + एक शुद्ध-काले डार्क थीम का उपयोग करें + %d Hz + परिवर्तनों के लिए आपकी संगीत लाइब्रेरी की निगरानी… + ईपी + एकल + एकल + एल्बम + साउंडट्रैक + साउंडट्रैकस + मिश्रित टेपस + मिश्रित टेप + रीमिक्स + रहना + प्लेलिस्ट का नाम बदलें + हटाएँ + संपादन करें + व्यवहार + + %d कलाकार + %d कलाकार + + संगीत लाइब्रेरी की निगरानी + जोड़ें + ईपी + नाम बदलें + अर्धविराम (;) + डीजे मिक्स + डीजे मिक्स + प्लेलिस्ट हटा दी गई + दिनांक + अवधि + गीतों की गिनती + डिस्क + ट्रैक + डिस्क %d + सैंपल रेट + गुणधर्म देखें + गीत के गुणधर्म + डिस्पले + कस्टम नोटीफिकेशन एक्शन + कलाकार से चलाएं + शैली से चलाएं + फेरबदल याद रखें + नया गाना बजाते समय फेरबदल करते रहें + मल्टी-मूल्य विभाजक + लोड की गई शैलियाँ: %d + लोड किए गए एल्बम: %d + आपकी संगीत लाइब्रेरी लोड कर रहे हैं... (%1$d/%2$d) + %d kbps + आपकी संगीत लाइब्रेरी लोड कर रहे हैं… + प्लेलिस्ट बनाई गई + दिखाई देता है + साझा करें + शफल करें + स्थिति बहाल + प्लेलिस्ट का नाम बदला गया + अलेक्जेंडर कैपहार्ट द्वारा विकसित + एकाधिक टैग मानों को निरूपित करने वाले वर्ण कॉन्फ़िगर करें + अल्पविराम (,) + स्लैश (/) + -%.1f dB + संपादन %s \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 783f6f805..7382cab1d 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -149,10 +149,10 @@ %d kbps %d Hz Učitavanje tvoje zbirke blazbe … (%1$d/%2$d) - Učitano pjesama: %d - Učitano albuma: %d - Učitanih izvođača: %d - Učitano žanrova: %d + Broj učitanih pjesama: %d + Broj učitanih albuma: %d + Broj učitanih izvođača: %d + Broj učitanih žanrova: %d Ukupno trajanje: %s %d pjesma @@ -215,13 +215,13 @@ Ampersand (&) Kompilacija uživo Kompilacija remiksa - Kompilacije + DJ kompilacije Znakovi odjeljivanja vrijednosti Prekini reprodukciju Konfiguriraj znakove koji označavaju višestruke vrijednosti oznaka Kosa crta (/) Plus (+) - Kompilacija + DJ kompilacija Točka-zarez (;) Prilagođena radnja trake reprodukcije Ekvilajzer @@ -286,4 +286,8 @@ Dodano u popis pjesama Uredi Izbrisati %s\? To je nepovratna radnja. + Uređivanje popisa pjesama %s + Sudjelovanja: + Dijeli + Nema diska \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 964385c34..fb003c4c8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -8,7 +8,7 @@ Permetti Generi Artisti - Dischi + Album Canzoni Tutte le canzoni Cerca @@ -17,21 +17,21 @@ Ordine Nome Artista - Disco + Album Anno Ascendente Ora in riproduzione Riproduci Mescola Riproduci da tutte le canzoni - Riproduci dal disco + Riproduci dall\'album Riproduci dall\'artista Coda Riproduci successivo Accoda Accodato Vai all\'artista - Vai al disco + Vai all\'album Stato salvato Aggiungi Salva @@ -100,8 +100,8 @@ Cancella la query di ricerca Rimuovi cartella Icona Auxio - Copertina disco - Copertina disco per %s + Copertina album + Copertina album per %s Immagine artista per %s Immagine genere per %s @@ -129,7 +129,7 @@ Grigio Canzoni trovate: %d - Dischi trovati: %d + Album trovati: %d Artisti trovati: %d Generi trovati: %d Durata totale: %s @@ -139,9 +139,9 @@ %d canzoni - %d disco - %d dischi - %d dischi + %d album + %d album + %d album Modo La musica non sarà caricata dalle cartelle che aggiungi. @@ -169,7 +169,7 @@ Free Lossless Audio Codec (FLAC) Advanced Audio Coding (AAC) Disco %d - %d kbps + %d kB/s -%.1f dB Caricamento musica Caricamento libreria musicale… @@ -240,8 +240,8 @@ E commerciale (&) Raccolte live Raccolta di remix - Mixes - Mix + Mix DJ + Mix DJ Alta qualità Virgola (,) Punto e virgola (;) @@ -257,7 +257,7 @@ Impossibile svuotare Mescola selezionati Riproduci selezionati - %d Selezionati + %d selezionati Riproduci dal genere Wiki %1$s, %2$s @@ -295,4 +295,8 @@ Eliminare %s\? L\'operazione non può essere annullata. Playlist eliminata Playlist rinominata + Condividi + Nessun disco + Appare su + Modifica di %s \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 263a5a955..2c89cd98d 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -9,7 +9,7 @@ Artiesten Albums Nummers - Alle Nummers + Alle nummers Zoeken Filter Alles @@ -20,7 +20,7 @@ Speel van alle nummers Speel af van album Speel van artiest - Afspeelscherm + Nu afspelen Wachtrij Afspelen als volgende Toevoegen aan wachtrij @@ -30,15 +30,15 @@ Staat gered Toevoegen Opslaan - Geen Mappen + Geen mappen Over Versie - Bekijken op GitHub + Broncode Licenties - Ontwikkeld door OxygenCobalt + Ontwikkeld door Alexander Capehart Instellingen - Uiterlijk + Uiterlijk en gevoel Thema Automatisch Licht @@ -60,36 +60,36 @@ Geen muziek aangetroffen Laden van muziek mislukt Auxio heeft toestemming nodig om uw muziekbibliotheek te lezen - Geen app kan deze link openen + Geen app gevonden die deze taak kan uitvoeren Deze map wordt niet ondersteund Zoek in uw bibliotheek… Nummer %d - Afspelen/Pauzeren + Afspelen of pauzeren Naar volgend nummer gaan Naar het laatste nummer gaan Herhaalfunctie wijzigen Zoekopdracht wissen - Verwijder uitgesloten map + Map verwijderen Auxio pictogram - Artist Image voor %s - Artist Image voor %s - Genre Image voor %s + Albumhoes voor %s + Artiesten-afbeelding voor %s + Genre-afbeelding voor %s - Onbekend Genre - Geen datum + Onbekend genre + Geen datum Rood Roze Paars - Donkerpaars + Dieppaars Indigoblauw Blauw - Donkerblauw + Diepblauw Blauwgroen Groen - Donkergroen + Diepgroen Cyaan Geelgroen Geel @@ -99,14 +99,14 @@ Nummers geladen: %d - %d Nummer - %d Nummers + %d lied + %d liedjes - %d Album - %d Albums + %d album + %d albums - Onbekend Artist + Onbekende artiest Zwart thema Gebruik een puur-zwart donker thema Pauze op herhaling @@ -118,7 +118,7 @@ Bekijk eigenschappen Naam Artiest - @android:string/cancel + Annuleren Bibliotheek tabbladen Jaar Ouderpad @@ -133,25 +133,25 @@ Modus Bepaal waar muziek vandaan moet worden geladen Totale duur: %s - Shuffle Alles - @android:string/ok + Alles schudden + Oké Altijd beginnen met spelen als een headset is aangesloten (werkt mogelijk niet op alle apparaten) Schakel shuffle aan of uit - Kan afspeelstatus wissen + De muziekbibliotheek opnieuw laden, indien mogelijk met behulp van tags uit het cachegeheugen Uw muziekbibliotheek wordt geladen… (%1$d/%2$d) Uitgezonderd Alle liedjes shuffelen Includeer Pauze wanneer een liedje wordt herhaald Muziek zal niet worden geladen vanuit de mappen die u toevoegt. - Muziek herladen + Muziek verfrissen Muziek zal alleen worden geladen uit de mappen die u toevoegt. Aanpassing met tags Aanpassing zonder tags Er speelt geen muziek Bij het afspelen van item details Ronde modus - Afgeronde hoeken op extra UI-elementen inschakelen (vereist dat albumhoezen afgerond zijn) + Afgeronde hoeken inschakelen voor extra UI-elementen (vereist dat albumhoezen zijn afgerond) Staat gerestaureerd Bibliotheekstatistieken Verander de zichtbaarheid en volgorde van bibliotheek-tabbladen @@ -161,18 +161,18 @@ Afspelen vanaf getoond item Afspeelstatus herstellen Herstel de eerder opgeslagen afspeelstatus (indien aanwezig) - Geen staat kan hersteld worden + Kan status niet herstellen Verwijder dit wachtrij liedje Verplaats dit wachtrij liedje Verplaats deze tab Album cover - Geen tracknummer + Geen nummer -%.1f dB Dynamisch - MPEG-1 Audio - MPEG-4 Audio - Ogg Audio - Matroska Audio + MPEG-1 audio + MPEG-4-audio + Ogg audio + Matroska-audio Albums geladen: %d Artiesten geladen: %d Genres geladen: %d @@ -189,4 +189,111 @@ Shuffle Geavanceerde audio codering (GAC) Gratis verliesvrije audiocodec (GVAC) + Nieuwe afspeellijst + Afspeelstatus gewist + Geluids- en afspeelgedrag configureren + Een nieuwe afspeellijst maken + %d Geselecteerd + Toevoegen aan afspeellijst + Afspeellijst gemaakt + Toegevoegd aan afspeellijst + Het thema en de kleuren van de app wijzigen + UI-besturingselementen en gedrag aanpassen + Bepaal hoe muziek en afbeeldingen worden geladen + Muziek + Automatisch herladen + Puntkomma (;) + Komma (,) + Plus (+) + Waarschuwing: Het gebruik van deze instelling kan ertoe leiden dat sommige tags verkeerd geïnterpreteerd worden als tags met meerdere waarden. U kunt dit oplossen door ongewenste scheidingstekens vooraf te laten gaan door een backslash (\\). + Toon alleen artiesten die rechtstreeks op een album worden genoemd (werkt het beste op goed getagde bibliotheken) + Sorteer namen die beginnen met cijfers of woorden zoals \"de\" correct (werkt het beste met Engelstalige muziek) + Stop met afspelen + Geselecteerd afspelen + Uw muziekbibliotheek wordt geladen… + Gedrag + Remix compilatie + Soundtrack + Mixtape + DJ-mix + Remixen + Schuine streep (/) + WeergaveWinst + Volharding + Afspeellijst + Wiki + Kan status niet opslaan + Resetten + Afbeeldingen + Afspeelstatus wissen + Snel + Bibliotheek + Live EP + Remix EP + Live single + Remix single + Compilaties + Compilatie + Live + Afspeellijst hernoemen + Afspeellijst verwijderen\? + Herhaalmodus + Mappen + De wachtrij openen + %s verwijderen\? Dit kan niet ongedaan worden gemaakt. + Uit + Hoge kwaliteit + Genre + Ampersand (&) + Bewerken + Aflopend + Kan status niet wissen + Afspeellijst-afbeelding voor %s + Geen nummers + Gelijkmaker + Singles + Single + EP\'s + EP + Live album + Remix album + Soundtracks + Mixtapes + Muziek laden + Muziekbibliotheek bewaken + Live compilatie + DJ-mixen + Uw muziekbibliotheek controleren op wijzigingen… + Afspeellijst hernoemd + Hernoemen + Aangepaste afspeelbalkactie + Tekens configureren die meerdere tagwaarden aanduiden + Verwijderen + Scheiders met meerdere waarden + Verberg bijdragers + Speel vanuit genre + Datum toegevoegd + %1$s, %2$s + Afspeellijst %d + + %d artiest + %d artiesten + + Shuffle geselecteerd + Intelligent sorteren + Verschijnt op + Afspeellijsten + Delen + Afspeellijst verwijderd + Naar volgende + Laad de muziekbibliotheek opnieuw wanneer deze wordt gewijzigd (vereist permanente melding) + Niet-muziek uitsluiten + Negeer audiobestanden die geen muziek zijn, zoals podcasts + Albumhoezen + Afspeel + Muziek opnieuw scannen + De tag-cache wissen en de muziekbibliotheek volledig opnieuw laden (langzamer, maar vollediger) + De eerder opgeslagen afspeelstatus wissen (indien aanwezig) + Geen schijf + %s aan het bewerken \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 7e70b3cda..f51c29ee1 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -27,8 +27,8 @@ ਸਾਊਂਡਟ੍ਰੈਕ ਸਾਊਂਡਟ੍ਰੈਕਸ ਮਿਕਸਟੇਪਸ - ਮਿਕਸ - ਮਿਕਸ + ਡੀਜੇ ਮਿਕਸ + ਡੀਜੇ ਮਿਕਸ ਲਾਈਵ ਰੀਮਿਕਸ ਕਲਾਕਾਰ @@ -180,7 +180,7 @@ ਕੌਮਾ (,) ਸੈਮੀਕੋਲਨ (;) ਸਲੈਸ਼ (/) - ਐਂਪਰਸੈਂਡ (&) + Ampersand (&) ਸਹਿਯੋਗੀਆਂ ਨੂੰ ਲੁਕਾਓ ਆਵਾਜ਼ ਅਤੇ ਪਲੇਬੈਕ ਵਿਵਹਾਰ ਦੀ ਸੰਰਚਨਾ ਕਰੋ ਪਲੇਅਬੈਕ @@ -202,4 +202,89 @@ ਸ਼ਫਲ ਚਾਲੂ ਜਾਂ ਬੰਦ ਕਰੋ ਸਾਰੇ ਗੀਤਾਂ ਨੂੰ ਸ਼ਫਲ ਕਰੋ ਪਲੇਬੈਕ ਬੰਦ ਕਰੋ + ਨਵੀਂ ਪਲੇਅ-ਲਿਸਟ + ਪਲੇਅ-ਲਿਸਟ ਵਿੱਚ ਜੋੜ੍ਹੋ + %1$s, %2$s + ਫਿੱਕਾ ਨੀਲਾ-ਹਰਾ + ਪਲੇਅ-ਲਿਸਟ + ਪਲੇਅ-ਲਿਸਟ ਮਿਟਾਈ + ਪਲੇਅ-ਲਿਸਟ ਬਣ ਗਈ + ਪਲੇਅ-ਲਿਸਟ ਦਾ ਨਾਂ ਬਦਲਿਆ + ਨੀਲਾ-ਹਰਾ + ਮਿਟਾਓ + ਇੱਕ ਨਵੀਂ ਪਲੇਅ-ਲਿਸਟ ਬਣਾਓ + ਪਲੇਅ- ਲਿਸਟ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕੀਤਾ + ਇਹ ਟੈਬ ਹਿਲਾਓ + ਕੋਈ ਗੀਤ ਨਹੀਂ + Matroska ਆਡੀਓ + ਗੂੜ੍ਹਾ ਜ੍ਹਾਮਣੀ + Ogg ਆਡੀਓ + % d: ਗੀਤ ਲੋਡ ਕੀਤੇ + + %d ਐਲਬਮ + %d ਐਲਬਮਾਂ + + ਨਾਂ ਬਦਲੋ + ਪਲੇਅ-ਲਿਸਟ ਦਾ ਨਾਂ ਬਦਲੋ + ਪਲੇਅ-ਲਿਸਟ ਮਿਟਾਓ\? + ਸੂਝ-ਬੂਝ ਨਾਲ ਲੜੀਬੱਧ + ਉਹਨਾਂ ਨਾਵਾਂ ਨੂੰ ਠੀਕ ਤਰ੍ਹਾਂ ਲੜੀਬੱਧ ਕਰੋ ਜੋ ਨੰਬਰਾਂ ਜਾਂ ਸ਼ਬਦਾਂ ਨਾਲ ਸ਼ੁਰੂ ਹੁੰਦੇ ਹਨ ਜਿਵੇਂ ਕਿ \"ਦਾ\" (ਅੰਗਰੇਜ਼ੀ-ਭਾਸ਼ਾ ਦੇ ਸੰਗੀਤ ਦੇ ਨਾਲ ਸਭ ਤੋਂ ਵਧੀਆ ਕੰਮ ਕਰਦਾ ਹੈ) + %s ਹਟਾਉਣਾ ਹੈ\? ਇਸ ਨੂੰ ਅਣਕੀਤਾ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ। + ਗੂੜ੍ਹਾ ਨੀਲਾ + + %d ਕਲਾਕਾਰ + %d ਕਲਾਕਾਰ + + ਡਿਸਕ %d + ਨੀਲਾ + ਪਲੇਅ-ਲਿਸਟ %d + MPEG-1 ਆਡੀਓ + MPEG-4 ਆਡੀਓ + %d ਚੁਣੇ + ਹਰਾ + ਖੋਜ ਕਰੀ ਸਾਫ਼ ਕਰੋ + ਕੋਈ ਟਰੈਕ ਨਹੀਂ + ਗੁਲਾਬੀ + ਜ੍ਹਾਮਣੀ + ਅਡਵਾਂਸਡ ਆਡੀਓ ਕੋਡਿੰਗ (AAC) + ਫਰੀ ਲੂਜ਼ਲੈੱਸ ਆਡੀਓ Codec (FLAC) + ਲਾਲ + ਸੰਤਰੀ + %d: ਸ਼ੈਲੀਆਂ ਲੋਡ ਕੀਤੀਆਂ + %d: ਐਲਬਮ ਲੋਡ ਕੀਤੇ + %d: ਕਲਾਕਾਰ ਲੋਡ ਕੀਤੇ + ਸੋਧ ਕਰੋ + %s ਲਈ ਐਲਬਮ ਕਵਰ + ਐਲਬਮ ਕਵਰ + %s ਲਈ ਕਲਾਕਾਰ ਚਿੱਤਰ + %s ਲਈ ਸ਼ੈਲੀ ਚਿੱਤਰ + %s ਲਈ ਪਲੇਅ-ਲਿਸਟ ਚਿੱਤਰ + ਅਣਜਾਣ ਕਲਾਕਾਰ + ਅਣਜਾਣ ਸ਼ੈਲੀ + ਕੋਈ ਮਿਤੀ ਨਹੀਂ + ਕੋਈ ਸੰਗੀਤ ਨਹੀਂ ਚੱਲ ਰਿਹਾ + ਗੂੜ੍ਹਾ ਹਰਾ + ਪੀਲ੍ਹਾ + ਨਿੰਬੂ ਰੰਗਾ + +%.1f dB + %d kbps + %d Hz + ਭੂਰਾ + -%.1f dB + + %d ਗੀਤ + %d ਗੀਤ + + ਸਲੇਟੀ + ਕੁੱਲ ਮਿਆਦ: %s + ਫੋਲਡਰ ਹਟਾਓ + Auxio ਆਈਕਾਨ + ਉੱਤੇ ਵਿਖਾਈ ਦਿੰਦਾ ਹੈ + ਪਲੇਅ-ਲਿਸਟਾਂ + ਸਾਂਝਾ ਕਰੋ + ਕੋਈ ਡਿਸਕ ਨਹੀਂ + ਬੈਂਗਣੀਂ + ਡਾਇਨੈਮਿਕ + %s ਸੋਧ ਰਿਹਾ + ਤੁਹਾਡੀ ਸੰਗੀਤ ਲਾਇਬਰੇਰੀ ਲੋਡ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ… (%1$d/%2$d) \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 26f8b19bc..6091d3ea4 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -191,7 +191,7 @@ Концертный альбом Концертный Мониторинг изменений в музыкальной библиотеке… - Позиция очищена + Позиция сброшена Папки с музыкой Включить Альбом ремиксов diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index db0b15c59..99d462df2 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -275,4 +275,9 @@ Sıralama yaparken makaleleri yoksay Ada göre sıralarken \"the\" gibi kelimeleri yok sayın (en iyi ingilizce müzikle çalışır) Yeni bir oynatma listesi oluştur + Yeni Oynatma Listesi + Sil + Yeniden Adlandır + Oynatma Listesini Yeniden Adlandır + Oynatma listesini silmek istiyor musun\? \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 038e65384..a9a5c1e6a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -27,7 +27,7 @@ Ліцензії Налаштування - Вигляд і поведінка + Вигляд Тема Світла Темна diff --git a/app/src/main/res/xml/backup_descriptor.xml b/app/src/main/res/xml/backup_descriptor.xml index 1b0854f7d..587567dab 100644 --- a/app/src/main/res/xml/backup_descriptor.xml +++ b/app/src/main/res/xml/backup_descriptor.xml @@ -1,2 +1,5 @@ - + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 40ee2c3f7..a95e572f9 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,5 +1,10 @@ - - + + + + + + + \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/32.txt b/fastlane/metadata/android/en-US/changelogs/32.txt new file mode 100644 index 000000000..c43912c26 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/32.txt @@ -0,0 +1,3 @@ +Auxio 3.1.0 introduces playlisting functionality, with more features coming soon. +This release adds some minor UI fixes and quality of life improvements. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.1.2. \ No newline at end of file diff --git a/fastlane/metadata/android/fi/short_description.txt b/fastlane/metadata/android/fi/short_description.txt new file mode 100644 index 000000000..f145fe7da --- /dev/null +++ b/fastlane/metadata/android/fi/short_description.txt @@ -0,0 +1 @@ +Yksinkertainen ja rationaallinen musiikkisoitin diff --git a/fastlane/metadata/android/hi/full_description.txt b/fastlane/metadata/android/hi/full_description.txt index 2b9dea82a..b22a5b7c4 100644 --- a/fastlane/metadata/android/hi/full_description.txt +++ b/fastlane/metadata/android/hi/full_description.txt @@ -10,7 +10,8 @@ Auxio एक तेज़, विश्वसनीय UI/UX वाला एक - डिस्क संख्या, एकाधिक कलाकार, रिलीज़ प्रकार, सटीक के लिए समर्थन / मूल दिनांक, सॉर्ट टैग, और बहुत कुछ - उन्नत कलाकार प्रणाली जो कलाकारों और एल्बम कलाकारों को एकजुट करती है - एसडी कार्ड-जागरूक फ़ोल्डर प्रबंधन -- विश्वसनीय प्लेबैक स्थिति दृढ़ता +- विश्वसनीय प्लेलिस्टिंग कार्यक्षमता +- प्लेबैक अवस्था दृढ़ता - पूर्ण रीप्लेगैन समर्थन (MP3, FLAC, OGG, OPUS और MP4 फ़ाइलों पर) - बाहरी तुल्यकारक समर्थन (उदा। वेवलेट) - एज-टू-एज diff --git a/fastlane/metadata/android/hr/full_description.txt b/fastlane/metadata/android/hr/full_description.txt index ee782d658..3973c6e3a 100644 --- a/fastlane/metadata/android/hr/full_description.txt +++ b/fastlane/metadata/android/hr/full_description.txt @@ -1,22 +1,23 @@ -Auxio je lokalni izvođač glazbe s brzim i pouzdanim korisničkim sučeljem/korisničkim iskustvom bez nepotrebnih značajki koje su prisutne u ostalim izvođačima glazbe. Kreiran od Exoplayera, Auxio ima vrhunsku podršku za biblioteke i kvalitetu slušanja u usporedbi s drugim aplikacijama koje koriste zastarjelu funkcionalnost Androida. Ukratko, reproducira glazbu. +Auxio je lokalni glazbeni player s brzim, pouzdanim UI/UX bez mnogih beskorisnih značajki prisutnih u drugim glazbenim playerima. Izgrađen na temelju ExoPlayera, Auxio ima vrhunsku podršku za biblioteku i kvalitetu slušanja u usporedbi s drugim aplikacijama koje koriste zastarjelu funkcionalnost Androida. Ukratko, Reproducira glazbu. Značajke -- Reprodukcija bazirana na ExoPlayeru -- Brzo korisničko sučelje u skladu s najnovijim Materijal dizajnom -- Korisničko iskustvo koje priorizira jednostavnost korištenja -- Prilagodljive ponašanje aplikacije -- Podrška za brojeve diskova, izvođače, vrste izdanja, -precizne/izvorne datume, oznake razvrstavanje i još više +- Reprodukcija temeljena na ExoPlayeru +- Snappy UI izvedeno iz najnovijih smjernica za materijalni dizajn +- Iskustveni korisnički doživljaj koji daje prednost jednostavnosti upotrebe u odnosu na rubne slučajeve +- Prilagodljivo ponašanje +- Podrška za brojeve diskova, više izvođača, vrste izdanja, +precizni/izvorni datumi, sortiranje oznaka i više - Napredni sustav izvođača koji ujedinjuje izvođače i izvođače albuma -- Upravljanje mapama SD kartica -- Pouzdana postojanost stanja reprodukcije -- Potpuna ReplayGain podrška (Za MP3, MP4, FLAC, OGG, i OPUS formate) -- Podrška za eksterne ekvilajzere (npr. Wavelet) -- Prikaz od ruba do ruba -- Podrška za ugrađene omote -- Pretražinje -- Mogućnost pokretanja glazbe čim spojite slušalice -- Stilizirani widgeti koji automatski prilagođavaju svoju veličinu -- Potpuno privatan bez potrebe za internetskom vezom -- Bez zaobljenih omota albuma (Osim ako ih želite. Onda ih možete imati.) +- Upravljanje mapama koje podržava SD karticu +- Pouzdana funkcija popisa pjesama +- Postojanost stanja reprodukcije +- Puna podrška za ReplayGain (na MP3, FLAC, OGG, OPUS i MP4 datotekama) +- Podrška za vanjski ekvilizator (npr. Wavelet) +- Od ruba do ruba +- Podrška za ugrađene naslovnice +- Funkcionalnost pretraživanja +- Automatska reprodukcija slušalica +- Elegantni widgeti koji se automatski prilagođavaju njihovoj veličini +- Potpuno privatno i izvan mreže +- Nema zaobljenih naslovnica albuma (Osim ako ih ne želite. Onda možete.) diff --git a/fastlane/metadata/android/ko/full_description.txt b/fastlane/metadata/android/ko/full_description.txt index b6bf66379..0a1497368 100644 --- a/fastlane/metadata/android/ko/full_description.txt +++ b/fastlane/metadata/android/ko/full_description.txt @@ -6,12 +6,14 @@ Auxio는 다른 음악 플레이어에 존재하는 많은 쓸모없는 기능 - 최신주목할 만한 디자인 가이드라인에서 파생된 Snappy UI - 엣지 케이스보다 사용 편의성을 우선시하는 의견이 많은 UX - 사용자 정의 가능한 동작 -- 올바른 메타데이터의 우선 순위를 지정하는 고급 미디어 인덱서 -- 정확한/원래 날짜, 정렬 태그 및 릴리스 유형 지원(실험적) +- 디스크 번호, 여러 아티스트, 릴리스 유형 지원, +정확한/원본 날짜, 정렬 태그 등 지원 +- 아티스트와 앨범 아티스트를 통합하는 고급 아티스트 시스템 - SD 카드 인식 폴더 관리 -- 안정적인 재생 상태 지속성 -- 완전한 ReplayGain 지원 (MP3, MP4, FLAC, OGG, OPUS) -- 외부 이퀄라이저 기능 (Wavelet과 같은 앱) +- 안정적인 재생 목록 기능 +- 재생 상태 지속성 +- 전체 ReplayGain 지원 (MP3, FLAC, OGG, OPUS, MP4) +- 외부 이퀄라이저 지원 (예: Wavelet) - Edge-to-edge - 임베디드 커버 지원 - 검색 기능 diff --git a/fastlane/metadata/android/pa/full_description.txt b/fastlane/metadata/android/pa/full_description.txt index 1fc6c904d..d4b927d7b 100644 --- a/fastlane/metadata/android/pa/full_description.txt +++ b/fastlane/metadata/android/pa/full_description.txt @@ -8,7 +8,10 @@ Auxio ਇੱਕ ਤੇਜ਼, ਭਰੋਸੇਮੰਦ UI/UX ਵਾਲਾ ਇੱ - ਅਨੁਕੂਲਿਤ ਵਿਵਹਾਰ - ਡਿਸਕ ਨੰਬਰਾਂ, ਮਲਟੀਪਲ ਕਲਾਕਾਰਾਂ, ਰੀਲੀਜ਼ ਕਿਸਮਾਂ, ਸਟੀਕ ਲਈ ਸਮਰਥਨ /ਮੂਲ ਤਾਰੀਖਾਂ, ਕ੍ਰਮਬੱਧ ਟੈਗਸ, ਅਤੇ ਹੋਰ - ਉੱਨਤ ਕਲਾਕਾਰ ਪ੍ਰਣਾਲੀ ਜੋ ਕਲਾਕਾਰਾਂ ਅਤੇ ਐਲਬਮ ਕਲਾਕਾਰਾਂ ਨੂੰ ਇਕਜੁੱਟ ਕਰਦੀ ਹੈ -- SD ਕਾਰਡ-ਜਾਣੂ ਫੋਲਡਰ ਪ੍ਰਬੰਧਨ - ਭਰੋਸੇਯੋਗ ਪਲੇਬੈਕ ਸਥਿਤੀ ਸਥਿਰਤਾ - ਪੂਰਾ ਰੀਪਲੇਗੇਨ ਸਮਰਥਨ (MP3, FLAC, OGG, OPUS, ਅਤੇ MP4 ਫਾਈਲਾਂ 'ਤੇ) +- SD ਕਾਰਡ-ਜਾਣੂ ਫੋਲਡਰ ਪ੍ਰਬੰਧਨ +- ਭਰੋਸੇਯੋਗ ਪਲੇਅਲਿਸਟਿੰਗ ਕਾਰਜਕੁਸ਼ਲਤਾ +- ਭਰੋਸੇਯੋਗ ਪਲੇਅਬੈਕ ਸਥਿਤੀ ਸਥਿਰਤਾ +- ਪੂਰਾ ਰੀਪਲੇਗੇਨ ਸਮਰਥਨ (MP3, FLAC, OGG, OPUS, ਅਤੇ MP4 ਫਾਈਲਾਂ 'ਤੇ) - ਬਾਹਰੀ ਈਕੋਲਾਈਜ਼ਰ ਦਾ ਸਮਰਥਨ (ਉਦਾਹਰਨ. ਵੇਵਲੇਟ) - ਕਿਨਾਰੇ-ਤੋਂ-ਕਿਨਾਰੇ - ਏਮਬੈਡਡ ਕਵਰ ਸਪੋਰਟ