Skip to content
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Cover art:** Search Deezer, iTunes and the Cover Art Archive for an album's cover, compare results by resolution and size, and apply one.
- **Cover art:** Pick a cover for an album from your gallery.
- **Cover art:** Choose where applied covers are kept, in Settings → Library → Album art storage.
- **Cover art:** Optionally look up covers for albums that have none, after a library scan. Off by default, Wi-Fi only, confident matches only.
- **Cover art:** Optional web image search for releases no catalog carries, using your own Serper API key.

### Changed
- **Cover art:** Changing only a cover now keeps it in PixelPlayer rather than writing it into the audio file; other tag edits are unchanged. Switch back under Settings → Library → Album art storage.
- **Cover art:** Covers applied to an album are stored once for the whole album, as WebP, instead of once per track.

### Fixed
- **Metadata:** Editing one field across several tracks no longer rewrites the fields you left alone. Multi-artist tags kept their own spelling, titles were replaced with the library's version, and the composer was removed from every track edited this way.
- **Metadata:** Lyrics fetched inside PixelPlayer are no longer written into your audio files by an edit that was not about lyrics.
- **Album art:** Album grids and album headers now update when a track's cover changes, instead of showing the previous cover until the next library scan.

## [0.7.5-beta] - 2026-06-13

### Added
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@
- **Lyrics Editing** - Modify or add lyrics to your tracks
- **Scrolling Display** - Follow along as you listen

### 🖼️ Artist Artwork
### 🖼️ Artwork
- **Deezer Integration** - Automatic artist images from Deezer API
- **Cover Art Search** - Find album covers on Deezer, iTunes and the Cover Art Archive
- **Automatic Covers** - Optionally fill in albums missing artwork after a scan
- **Smart Caching** - Memory (LRU) + database caching for offline access
- **Fallback Icons** - Beautiful placeholders when images unavailable

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.theveloper.pixelplay.data.coverart

/**
* Where an applied cover is kept.
*
* The two options differ in what survives the app: art written into the audio
* files travels with them to any other player or machine, while art kept in the
* app leaves the user's files untouched and disappears with the app's data.
*/
enum class AlbumArtStorage {
/**
* Embedded into every track of the album, the way a tag editor would.
*
* Modifying files the app did not create needs the user's consent per file
* on Android 11 and up, which can only be asked for on screen. Covers found
* by the unattended pass are therefore still kept in the app, whatever this
* is set to.
*/
AUDIO_FILES,

/**
* Kept in the app's own artwork store, leaving the audio files untouched.
*
* No write consent and no tag rewrite, at the cost of art no other player
* sees. The default: the unattended pass can only ever write here, and one
* store keeps "where is this album's cover" answerable.
*/
APP_ONLY
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package com.theveloper.pixelplay.data.coverart

import android.content.Context
import com.theveloper.pixelplay.data.database.AlbumArtThemeDao
import com.theveloper.pixelplay.data.database.MusicDao
import com.theveloper.pixelplay.data.media.ImageCacheManager
import com.theveloper.pixelplay.utils.AlbumArtUtils
import com.theveloper.pixelplay.utils.LocalArtworkUri
import timber.log.Timber
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

/**
* Applies a cover without touching the user's audio files.
*
* The image goes into the applied-artwork store, which nothing evicts; see
* [AlbumArtUtils.saveAppliedAlbumArt]. Shared by the unattended pass and by a
* manual apply under [AlbumArtStorage.APP_ONLY].
*/
@Singleton
class AppArtworkWriter @Inject constructor(
@ApplicationContext private val context: Context,
private val musicDao: MusicDao,
private val albumArtThemeDao: AlbumArtThemeDao,
private val imageCacheManager: ImageCacheManager
) {

private val _appliedArtworkRevision = MutableStateFlow(0L)

/**
* Bumped every time a song's artwork changes, so a screen can re-read it.
*
* The rows are not a signal on their own: [apply] writes the canonical URI,
* which is often the string the row already held. Covers writes into the
* audio files too, via [noteExternalArtworkChange].
*/
val appliedArtworkRevision: StateFlow<Long> = _appliedArtworkRevision.asStateFlow()

/**
* Records artwork changed outside this writer -- a cover written into the
* audio files themselves.
*
* Those writes leave the album row pointing at the URI it already held, so
* a header drawing from it has nothing else to tell it to reload, and they
* can supersede an applied cover, which leaves the "remove cover" entry
* describing a store that no longer holds one.
*/
fun noteExternalArtworkChange() {
_appliedArtworkRevision.update { it + 1 }
}

/**
* @param albumId the album these songs belong to, when they share one. Its
* row follows only if [songIds] covers the whole album -- one track of
* twenty is not the album getting a new cover.
* @return false when nothing was stored: an empty [songIds], or a failed
* write. A believed-but-absent apply would chain the automatic pass into
* re-fetching the same albums forever.
*/
suspend fun apply(
bytes: ByteArray,
songIds: List<Long>,
albumId: Long? = null
): Boolean = withContext(Dispatchers.IO) {
// Cloud tracks have a negative id and no local store to write into, so
// pointing their rows here would replace a working remote URI with one
// resolving to nothing. Both callers filter; repeating it makes it the
// writer's invariant rather than each caller's to remember.
val songIds = songIds.filter { it > 0 }
if (songIds.isEmpty()) return@withContext false

// One decode and re-encode for the album, not one per track: bounding
// is a full bitmap decode, scale and WebP encode.
val bounded = AlbumArtUtils.boundArtworkForStorage(bytes)

// A full disk is worth a log and an unchanged cover, not an exception
// escaping into a ViewModel's scope. The caller is still told: reporting
// success chained the automatic pass into re-fetching forever.
val stored = runCatching { AlbumArtUtils.saveAppliedAlbumArt(context, bounded, songIds) }
.onFailure { error -> Timber.w(error, "Could not store the applied cover") }
.getOrNull()
if (stored == null) return@withContext false

val artworkUris = songIds.map { songId ->
val artworkUri = LocalArtworkUri.buildSongUri(songId)
musicDao.updateSongAlbumArt(songId, artworkUri)
imageCacheManager.invalidateRenderedCoverArt(artworkUri)
artworkUri
}

if (coversWholeAlbum(albumId, songIds)) {
musicDao.updateAlbumArt(requireNotNull(albumId), artworkUris.first())
}

// The palette is derived from the old cover and keyed by a URI that has
// not changed, so nothing else would ever recompute it.
albumArtThemeDao.deleteThemesByUris(artworkUris)
_appliedArtworkRevision.update { it + 1 }
true
}

/**
* Takes back a cover applied to [songIds], leaving the audio files alone.
*
* Each song shows what it would have shown had the cover never been applied.
* Returns what each was left pointing at, since not every song keeps art.
*/
suspend fun removeApplied(
songIds: List<Long>,
albumId: Long? = null
): Map<Long, String?> = withContext(Dispatchers.IO) {
// Filtered for the same reason [apply] filters: a cloud track never had
// an applied cover to take back, and writing one's row here would
// re-point it at a local file that does not exist.
val songIds = songIds.filter { it > 0 }
if (songIds.isEmpty()) return@withContext emptyMap()

val artworkUris = songIds.map { LocalArtworkUri.buildSongUri(it) }
var remainingForAlbum: String? = null
val remaining = mutableMapOf<Long, String?>()

songIds.forEach { songId ->
AlbumArtUtils.clearAppliedArtForSong(context, songId)

// Asking for the artwork again is what re-extracts whatever the file
// still carries; a song with none is left pointing at nothing rather
// than at a URI that resolves to a blank.
val artworkUri = AlbumArtUtils.ensureAlbumArtCachedFile(context, songId)
?.let { LocalArtworkUri.buildSongUri(songId) }
musicDao.updateSongAlbumArt(songId, artworkUri)
imageCacheManager.invalidateRenderedCoverArt(LocalArtworkUri.buildSongUri(songId))
remaining[songId] = artworkUri
if (remainingForAlbum == null) remainingForAlbum = artworkUri
}

if (coversWholeAlbum(albumId, songIds)) {
musicDao.updateAlbumArt(requireNotNull(albumId), remainingForAlbum)
}

albumArtThemeDao.deleteThemesByUris(artworkUris)
_appliedArtworkRevision.update { it + 1 }
remaining
}

/**
* Whether [songIds] accounts for every track of [albumId] this writer can
* give a cover to. Cloud tracks are left out of the count as they are left
* out of the write, or an album holding one is permanently short.
*/
private suspend fun coversWholeAlbum(albumId: Long?, songIds: List<Long>): Boolean {
val id = albumId ?: return false
val albumSongIds = musicDao.getSongsByAlbumIdOnce(id).map { it.id }.filter { it > 0 }
return albumSongIds.isNotEmpty() && songIds.containsAll(albumSongIds)
}
}
Loading