From 69813dc2c1e505a255bfb096a80a3a121da6d5ba Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:01:51 +0300 Subject: [PATCH 01/18] Honor updated artist delimiters during rescans (#97) Changing artist parsing rules now forces embedded metadata to be read again, so comma and word delimiters are reflected without requiring a manual rebuild. --- .../data/worker/SyncExecutionPlan.kt | 6 ++- .../data/worker/SyncWorkerRequestTest.kt | 49 ++++++++++++++++--- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncExecutionPlan.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncExecutionPlan.kt index 0170698c..4f84273d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncExecutionPlan.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncExecutionPlan.kt @@ -31,7 +31,11 @@ internal fun buildSyncExecutionPlan( directoryRulesChanged || isFreshInstall val resetExistingLocalData = requestedMode == SyncMode.REBUILD - val effectiveForceMetadata = requestedForceMetadata || requestedMode == SyncMode.REBUILD + // Artist delimiter changes must re-read the physical tags as well as rebuild relationships. + // MediaStore often exposes only the first ARTIST value, especially for Vorbis comments. + val effectiveForceMetadata = requestedForceMetadata || + requestedMode == SyncMode.REBUILD || + rescanRequired val localScanMode = when { resetExistingLocalData -> LocalScanMode.LOCAL_REBUILD effectiveForceMetadata -> LocalScanMode.DEEP_RESCAN diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerRequestTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerRequestTest.kt index 162d5fe2..ecfa881c 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerRequestTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerRequestTest.kt @@ -63,20 +63,33 @@ class SyncWorkerRequestTest { } @Test - fun `artist or directory settings force local rescan for incremental requests`() { - val plan = buildSyncExecutionPlan( + fun `artist settings force deep metadata rescan while directory settings stay shallow`() { + val artistPlan = buildSyncExecutionPlan( requestedMode = SyncMode.INCREMENTAL, requestedForceMetadata = false, requestedRunMaintenance = false, rescanRequired = true, + directoryRulesChanged = false, + isFreshInstall = false + ) + + assertEquals(LocalScanMode.DEEP_RESCAN, artistPlan.localScanMode) + assertTrue(artistPlan.forceProcessAll) + assertTrue(artistPlan.forceMetadata) + assertFalse(artistPlan.runMaintenance) + + val directoryPlan = buildSyncExecutionPlan( + requestedMode = SyncMode.INCREMENTAL, + requestedForceMetadata = false, + requestedRunMaintenance = false, + rescanRequired = false, directoryRulesChanged = true, isFreshInstall = false ) - assertEquals(LocalScanMode.LOCAL_RESCAN, plan.localScanMode) - assertTrue(plan.forceProcessAll) - assertFalse(plan.forceMetadata) - assertFalse(plan.runMaintenance) + assertEquals(LocalScanMode.LOCAL_RESCAN, directoryPlan.localScanMode) + assertTrue(directoryPlan.forceProcessAll) + assertFalse(directoryPlan.forceMetadata) } @Test @@ -96,6 +109,30 @@ class SyncWorkerRequestTest { assertTrue(plan.resetExistingLocalData) } + @Test + fun `multi value tag formats read embedded artist values during a shallow scan`() { + listOf("song.mp3", "song.flac", "song.opus", "song.ogg", "song.oga").forEach { fileName -> + assertTrue( + shouldReadEmbeddedMetadata( + filePath = "/music/$fileName", + deepScan = false, + rawArtist = "MediaStore Artist", + rawAlbum = "MediaStore Album" + ), + fileName + ) + } + + assertFalse( + shouldReadEmbeddedMetadata( + filePath = "/music/song.m4a", + deepScan = false, + rawArtist = "MediaStore Artist", + rawAlbum = "MediaStore Album" + ) + ) + } + @Test fun `incremental timestamp includes overlap`() { val lastSyncMs = TimeUnit.SECONDS.toMillis(120) From c6c1f118ce4e232443349a301124878e4a61fcd9 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:02:05 +0300 Subject: [PATCH 02/18] Read every embedded artist tag (#98) Repeated ARTIST fields are preserved in source order, split with the configured delimiters, and deduplicated before artist relationships are rebuilt. --- .../data/media/AudioMetadataReader.kt | 103 +++++++++++-- .../data/worker/ArtistParsingUtils.kt | 31 +++- .../pixelplayeross/data/worker/SyncWorker.kt | 140 ++++++++++++------ .../data/media/AudioMetadataReaderTest.kt | 20 +++ .../repository/MusicRepositoryImplTest.kt | 2 + .../data/worker/ArtistParsingUtilsTest.kt | 44 ++++++ 6 files changed, 284 insertions(+), 56 deletions(-) create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/media/AudioMetadataReaderTest.kt diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/AudioMetadataReader.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/AudioMetadataReader.kt index 4e9c6e49..4f6cfaeb 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/AudioMetadataReader.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/AudioMetadataReader.kt @@ -6,12 +6,19 @@ import android.os.ParcelFileDescriptor import com.kyant.taglib.TagLib import org.jaudiotagger.audio.AudioFileIO import org.jaudiotagger.tag.FieldKey +import org.jaudiotagger.tag.flac.FlacTag +import org.jaudiotagger.tag.id3.AbstractID3v2Tag +import org.jaudiotagger.tag.mp4.Mp4Tag +import org.jaudiotagger.tag.vorbiscomment.VorbisCommentTag +import org.jaudiotagger.tag.wav.WavTag import timber.log.Timber import java.io.File data class AudioMetadata( val title: String?, val artist: String?, + /** Every physical ARTIST field, in tag order. [artist] remains the primary value. */ + val artists: List, val albumArtist: String?, val album: String?, val genre: String?, @@ -25,9 +32,33 @@ data class AudioMetadata( val sampleRate: Int?, val artwork: AudioMetadataArtwork?, val replayGainTrackGainDb: Float? = null, - val replayGainAlbumGainDb: Float? = null + val replayGainAlbumGainDb: Float? = null, + val rating: Int? = null, + val customFields: List = emptyList() ) +/** + * Normalizes values read from repeated metadata fields without changing their source order. + * + * Vorbis comments and some ID3 writers can store ARTIST more than once. Keeping this helper + * format-agnostic lets both TagLib's property map and JAudioTagger's field list use the exact + * same case-insensitive de-duplication policy. + */ +internal fun normalizeArtistMetadataValues( + vararg sources: Iterable? +): List { + val result = mutableListOf() + sources.forEach { source -> + source?.forEach { value -> + val normalized = value.trim() + if (normalized.isNotEmpty() && result.none { it.equals(normalized, ignoreCase = true) }) { + result += normalized + } + } + } + return result +} + data class AudioMetadataArtwork( val bytes: ByteArray, val mimeType: String? @@ -54,7 +85,11 @@ object AudioMetadataReader { } } - fun read(file: File, readArtwork: Boolean = true): AudioMetadata? { + fun read( + file: File, + readArtwork: Boolean = true, + readCustomMetadata: Boolean = false + ): AudioMetadata? { return try { ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd -> val audioProperties = TagLib.getAudioProperties(fd.dup().detachFd()) @@ -68,7 +103,8 @@ object AudioMetadataReader { Timber.tag(TAG).w("TagLib propertyMap keys for ${file.name}: ${propertyMap.keys}") val title = propertyMap["TITLE"]?.firstOrNull()?.takeIf { it.isNotBlank() } - val artist = propertyMap["ARTIST"]?.firstOrNull()?.takeIf { it.isNotBlank() } + val artists = normalizeArtistMetadataValues(propertyMap["ARTIST"]?.asIterable()) + val artist = artists.firstOrNull() val albumArtist = propertyMap["ALBUMARTIST"]?.firstOrNull()?.takeIf { it.isNotBlank() } ?: propertyMap["ALBUM ARTIST"]?.firstOrNull()?.takeIf { it.isNotBlank() } ?: propertyMap["BAND"]?.firstOrNull()?.takeIf { it.isNotBlank() } @@ -94,6 +130,19 @@ object AudioMetadataReader { propertyMap = propertyMap, keys = listOf("REPLAYGAIN_ALBUM_GAIN", "REPLAYGAIN_ALBUM_GAIN_DB", "R128_ALBUM_GAIN") ) + val tagFamily = metadataTagFamily(file.extension) + val propertyMapRating = if (readCustomMetadata) { + listOf("RATING", "POPULARIMETER") + .firstNotNullOfOrNull { key -> propertyMap[key]?.firstOrNull() } + ?.let { decodeRatingFromTag(it, tagFamily) } + } else { + null + } + val customFields = if (readCustomMetadata) { + extractEditableCustomMetadataFields(propertyMap) + } else { + emptyList() + } Timber.tag(TAG).w("TagLib result for ${file.name}: title=$title, artist=$artist, album=$album, genre=$genre") @@ -111,14 +160,19 @@ object AudioMetadataReader { null } - val fallback = if (title == null || artist == null || (readArtwork && artwork == null)) { + val fallback = if ( + title == null || artist == null || (readArtwork && artwork == null) || readCustomMetadata + ) { Timber.tag(TAG).w("TagLib incomplete for ${file.name}, trying JAudioTagger fallback...") - readWithJAudioTagger(file) + readWithJAudioTagger(file, readCustomMetadata = readCustomMetadata) } else null + val resolvedArtists = artists.ifEmpty { fallback?.artists.orEmpty() } + AudioMetadata( title = title ?: fallback?.title, - artist = artist ?: fallback?.artist, + artist = resolvedArtists.firstOrNull() ?: artist ?: fallback?.artist, + artists = resolvedArtists, albumArtist = albumArtist ?: fallback?.albumArtist, album = album ?: fallback?.album, genre = genre ?: fallback?.genre, @@ -132,7 +186,9 @@ object AudioMetadataReader { sampleRate = sampleRate ?: fallback?.sampleRate, artwork = artwork ?: fallback?.artwork, replayGainTrackGainDb = replayGainTrackGainDb ?: fallback?.replayGainTrackGainDb, - replayGainAlbumGainDb = replayGainAlbumGainDb ?: fallback?.replayGainAlbumGainDb + replayGainAlbumGainDb = replayGainAlbumGainDb ?: fallback?.replayGainAlbumGainDb, + rating = fallback?.rating ?: propertyMapRating, + customFields = customFields.ifEmpty { fallback?.customFields.orEmpty() } ) } } catch (error: Exception) { @@ -145,7 +201,10 @@ object AudioMetadataReader { * Fallback reader using JAudioTagger for files where TagLib can't map ID3 frames. * Called when TagLib leaves key metadata or requested artwork unresolved. */ - private fun readWithJAudioTagger(file: File): AudioMetadata? { + private fun readWithJAudioTagger( + file: File, + readCustomMetadata: Boolean = false + ): AudioMetadata? { return try { java.util.logging.Logger.getLogger("org.jaudiotagger").level = java.util.logging.Level.OFF @@ -157,7 +216,15 @@ object AudioMetadataReader { "header=${header?.format}, sampleRate=${header?.sampleRateAsNumber}") val title = tag?.getFirst(FieldKey.TITLE)?.takeIf { it.isNotBlank() } - val artist = tag?.getFirst(FieldKey.ARTIST)?.takeIf { it.isNotBlank() } + val artists = normalizeArtistMetadataValues( + runCatching { tag?.getAll(FieldKey.ARTIST) }.getOrNull(), + listOfNotNull( + runCatching { tag?.getFirst(FieldKey.ARTIST) } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ) + ) + val artist = artists.firstOrNull() val albumArtist = tag?.getFirst(FieldKey.ALBUM_ARTIST)?.takeIf { it.isNotBlank() } val album = tag?.getFirst(FieldKey.ALBUM)?.takeIf { it.isNotBlank() } val genre = tag?.getFirst(FieldKey.GENRE)?.takeIf { it.isNotBlank() } @@ -169,6 +236,20 @@ object AudioMetadataReader { ?.substringBefore('/')?.toIntOrNull() val year = tag?.getFirst(FieldKey.YEAR)?.takeIf { it.isNotBlank() } ?.take(4)?.toIntOrNull() + val rating = if (readCustomMetadata) { + val actualTagFamily = when (tag) { + is AbstractID3v2Tag, is WavTag -> MetadataTagFamily.ID3 + is FlacTag, is VorbisCommentTag -> MetadataTagFamily.VORBIS + is Mp4Tag -> MetadataTagFamily.MP4 + else -> metadataTagFamily(file.extension) + } + runCatching { tag?.getFirst(FieldKey.RATING) } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?.let { decodeRatingFromTag(it, actualTagFamily) } + } else { + null + } val durationMs = header?.trackLength?.takeIf { it > 0 }?.let { it * 1000L } val bitrate = header?.bitRateAsNumber?.takeIf { it > 0 }?.toInt()?.let { it * 1000 } @@ -189,6 +270,7 @@ object AudioMetadataReader { AudioMetadata( title = title, artist = artist, + artists = artists, albumArtist = albumArtist, album = album, genre = genre, @@ -200,7 +282,8 @@ object AudioMetadataReader { year = year, bitrate = bitrate, sampleRate = sampleRate, - artwork = artwork + artwork = artwork, + rating = rating ) } catch (e: Exception) { Timber.tag(TAG).e(e, "JAudioTagger fallback FAILED for: ${file.name}") diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtils.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtils.kt index b573c4b2..97a7282a 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtils.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtils.kt @@ -10,7 +10,36 @@ internal fun collectArtistNames( wordDelimiters: List = emptyList(), extractFromTitle: Boolean = true ): List { - val splitFromArtist = rawArtistName.splitArtistsByDelimiters(artistDelimiters, wordDelimiters) + return collectArtistNames( + rawArtistNames = listOf(rawArtistName), + title = title, + artistDelimiters = artistDelimiters, + wordDelimiters = wordDelimiters, + extractFromTitle = extractFromTitle + ) +} + +/** + * Splits every ARTIST field before case-insensitively de-duplicating the result. + * The physical tag order is retained, so the first value remains the primary artist. + */ +internal fun collectArtistNames( + rawArtistNames: List, + title: String, + artistDelimiters: List, + wordDelimiters: List = emptyList(), + extractFromTitle: Boolean = true +): List { + val splitFromArtist = mutableListOf() + rawArtistNames.forEach { rawArtistName -> + rawArtistName + .splitArtistsByDelimiters(artistDelimiters, wordDelimiters) + .forEach { artistName -> + if (splitFromArtist.none { it.equals(artistName, ignoreCase = true) }) { + splitFromArtist += artistName + } + } + } if (!extractFromTitle) { return splitFromArtist } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt index c77782c1..c05b5b98 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt @@ -24,6 +24,7 @@ import com.lostf1sh.pixelplayeross.data.database.serializeArtistRefs import com.lostf1sh.pixelplayeross.data.diagnostics.AdvancedPerformanceDiagnostics import com.lostf1sh.pixelplayeross.data.model.ArtistRef import com.lostf1sh.pixelplayeross.data.media.AudioMetadataReader +import com.lostf1sh.pixelplayeross.data.media.normalizeArtistMetadataValues import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.data.repository.LyricsRepository @@ -43,6 +44,7 @@ import java.io.File import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger +import java.util.Locale import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -60,6 +62,39 @@ enum class SyncMode { REBUILD } +private val EMBEDDED_METADATA_FIRST_EXTENSIONS = setOf( + "mp3", + "flac", + "wav", + "opus", + "ogg", + "oga", + "aiff", +) + +/** File formats whose tag models commonly retain metadata that MediaStore flattens or drops. */ +internal fun shouldReadEmbeddedMetadata( + filePath: String, + deepScan: Boolean, + rawArtist: String, + rawAlbum: String +): Boolean { + val extension = File(filePath).extension.lowercase(Locale.ROOT) + return deepScan || + extension in EMBEDDED_METADATA_FIRST_EXTENSIONS || + isDefaultMetadata(rawArtist) || + isDefaultMetadata(rawAlbum) +} + +private fun isDefaultMetadata(value: String): Boolean { + val lower = value.trim().lowercase(Locale.ROOT) + return lower.isEmpty() || + lower == "" || + lower == "unknown" || + lower == "unknown artist" || + lower == "unknown album" +} + @HiltWorker class SyncWorker @AssistedInject @@ -450,12 +485,22 @@ constructor( val crossRefs: List ) + /** + * Artist tag values only need to live between file scanning and relationship generation. + * Keeping them beside the entity avoids a database migration or a delimiter-dependent + * encoding in [SongEntity.artistName]. + */ + private data class ScannedSong( + val entity: SongEntity, + val artistValues: List + ) + /** * Process songs with multi-artist support. Splits artist names by delimiters and creates proper * cross-references. */ private fun preProcessAndDeduplicateWithMultiArtist( - songs: List, + songs: List, artistDelimiters: List, wordDelimiters: List = emptyList(), extractFromTitle: Boolean = true, @@ -483,14 +528,17 @@ constructor( } } - songs.forEach { song -> + songs.forEach { scannedSong -> + val song = scannedSong.entity val rawArtistName = song.artistName val songArtistNameTrimmed = rawArtistName.trim() val allArtistsForSong = - artistSplitCache.getOrPut("$rawArtistName\u0000${song.title}\u0000$extractFromTitle") { + artistSplitCache.getOrPut( + "${scannedSong.artistValues.joinToString("\u0001")}\u0000${song.title}\u0000$extractFromTitle" + ) { collectArtistNames( - rawArtistName = rawArtistName, + rawArtistNames = scannedSong.artistValues.ifEmpty { listOf(rawArtistName) }, title = song.title, artistDelimiters = artistDelimiters, wordDelimiters = wordDelimiters, @@ -766,7 +814,7 @@ constructor( resetExistingLocalData: Boolean, progressBatchSize: Int, onProgress: suspend (current: Int, total: Int, phaseOrdinal: Int) -> Unit - ): List = traceAsyncSection("SyncWorker.fetchMusicFromMediaStore") { + ): List = traceAsyncSection("SyncWorker.fetchMusicFromMediaStore") { val deepScan = forceMetadata val genreMap = fetchGenreMap() @@ -930,7 +978,7 @@ constructor( val concurrencyLimit = 4 val semaphore = Semaphore(concurrencyLimit) - val songs = mutableListOf() + val songs = mutableListOf() for (batch in songsToProcess.chunked(200)) { val ids = batch.map { it.id } val existingMap = if (resetExistingLocalData) emptyMap() else musicDao.getSongsByIdsListSimple(ids).associateBy { it.id } @@ -939,7 +987,7 @@ constructor( async { semaphore.withPermit { val localSong = existingMap[raw.id] - val mediaStoreSong = + val scannedMediaStoreSong = processSongData( raw = raw, genreMap = genreMap, @@ -948,37 +996,43 @@ constructor( ) val song = if (localSong != null) { - mediaStoreSong.copy( + scannedMediaStoreSong.entity.copy( dateAdded = if ( localSong.mediaStoreDateAdded > 0 && - localSong.mediaStoreDateAdded == mediaStoreSong.mediaStoreDateAdded + localSong.mediaStoreDateAdded == scannedMediaStoreSong.entity.mediaStoreDateAdded ) { localSong.dateAdded } else { - mediaStoreSong.dateAdded + scannedMediaStoreSong.entity.dateAdded }, lyrics = localSong.lyrics, - title = if (localSong.titleUserEdited) localSong.title else mediaStoreSong.title, - artistName = if (localSong.artistUserEdited) localSong.artistName else mediaStoreSong.artistName, - albumName = if (localSong.albumUserEdited) localSong.albumName else mediaStoreSong.albumName, - genre = if (localSong.genreUserEdited) localSong.genre else mediaStoreSong.genre, - trackNumber = if (localSong.trackNumber != 0) localSong.trackNumber else mediaStoreSong.trackNumber, - discNumber = localSong.discNumber ?: mediaStoreSong.discNumber, - albumArtUriString = mediaStoreSong.albumArtUriString, + title = if (localSong.titleUserEdited) localSong.title else scannedMediaStoreSong.entity.title, + artistName = if (localSong.artistUserEdited) localSong.artistName else scannedMediaStoreSong.entity.artistName, + albumName = if (localSong.albumUserEdited) localSong.albumName else scannedMediaStoreSong.entity.albumName, + genre = if (localSong.genreUserEdited) localSong.genre else scannedMediaStoreSong.entity.genre, + trackNumber = if (localSong.trackNumber != 0) localSong.trackNumber else scannedMediaStoreSong.entity.trackNumber, + discNumber = localSong.discNumber ?: scannedMediaStoreSong.entity.discNumber, + albumArtUriString = scannedMediaStoreSong.entity.albumArtUriString, titleUserEdited = localSong.titleUserEdited, artistUserEdited = localSong.artistUserEdited, albumUserEdited = localSong.albumUserEdited, genreUserEdited = localSong.genreUserEdited ) } else { - mediaStoreSong + scannedMediaStoreSong.entity + } + + val artistValues = if (localSong?.artistUserEdited == true) { + listOf(localSong.artistName) + } else { + scannedMediaStoreSong.artistValues } val count = processedCount.incrementAndGet() if (count % progressBatchSize == 0 || count == totalCount) { onProgress(count, totalCount, SyncProgress.SyncPhase.PROCESSING_FILES.ordinal) } - song + ScannedSong(entity = song, artistValues = artistValues) } } }.awaitAll() @@ -989,20 +1043,6 @@ constructor( songs } - /** - * Checks if a metadata field from MediaStore is a default/unknown placeholder. - * MediaStore uses `` for unreadable fields, and our normalization - * may fall back to `"Unknown Artist"` / `"Unknown Album"` etc. - */ - private fun isDefaultMetadata(value: String): Boolean { - val lower = value.trim().lowercase() - return lower.isEmpty() || - lower == "" || - lower == "unknown" || - lower == "unknown artist" || - lower == "unknown album" - } - /** * Process a single song's raw data into a SongEntity. This is the CPU/IO intensive work that * benefits from parallelization. @@ -1012,7 +1052,7 @@ constructor( genreMap: Map, deepScan: Boolean, forceAlbumArtRefresh: Boolean - ): SongEntity { + ): ScannedSong { val parentDir = java.io.File(raw.filePath).parent ?: "" val contentUriString = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, raw.id) @@ -1032,6 +1072,7 @@ constructor( var title = raw.title var artist = raw.artist + var artistValues = listOf(raw.artist) var album = raw.album var albumArtist = resolveAlbumArtist( rawAlbumArtist = raw.albumArtist, @@ -1042,15 +1083,12 @@ constructor( var year = raw.year var genre: String? = genreMap[raw.id] ?: raw.genre - val shouldAugmentMetadata = - deepScan || - raw.filePath.endsWith(".wav", true) || - raw.filePath.endsWith(".opus", true) || - raw.filePath.endsWith(".ogg", true) || - raw.filePath.endsWith(".oga", true) || - raw.filePath.endsWith(".aiff", true) || - isDefaultMetadata(raw.artist) || - isDefaultMetadata(raw.album) + val shouldAugmentMetadata = shouldReadEmbeddedMetadata( + filePath = raw.filePath, + deepScan = deepScan, + rawArtist = raw.artist, + rawAlbum = raw.album + ) if (shouldAugmentMetadata) { val file = java.io.File(raw.filePath) @@ -1058,7 +1096,16 @@ constructor( try { AudioMetadataReader.read(file, readArtwork = false)?.let { meta -> if (!meta.title.isNullOrBlank()) title = meta.title - if (!meta.artist.isNullOrBlank()) artist = meta.artist + if (meta.artists.isNotEmpty()) { + artist = meta.artists.first() + // Embedded fields are the authoritative physical ARTIST values here. + // Adding MediaStore's flattened display value can create a spurious + // extra artist when the platform joined repeated tags itself. + artistValues = normalizeArtistMetadataValues(meta.artists) + } else if (!meta.artist.isNullOrBlank()) { + artist = meta.artist + artistValues = normalizeArtistMetadataValues(listOf(meta.artist)) + } if (!meta.album.isNullOrBlank()) album = meta.album albumArtist = resolveAlbumArtist( rawAlbumArtist = albumArtist, @@ -1075,7 +1122,8 @@ constructor( } } - return SongEntity( + return ScannedSong( + entity = SongEntity( id = raw.id, title = title, artistName = artist, @@ -1103,6 +1151,8 @@ constructor( sourceType = SourceType.LOCAL, mediaStoreDateAdded = raw.dateAdded, mediaStoreDateModified = raw.dateModified + ), + artistValues = artistValues ) } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/AudioMetadataReaderTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/AudioMetadataReaderTest.kt new file mode 100644 index 00000000..1e43b5f0 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/AudioMetadataReaderTest.kt @@ -0,0 +1,20 @@ +package com.lostf1sh.pixelplayeross.data.media + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AudioMetadataReaderTest { + + @Test + fun `normalizeArtistMetadataValues preserves repeated fields in source order`() { + val values = normalizeArtistMetadataValues( + listOf("Primary Artist", " Guest Artist ", "primary artist", ""), + listOf("Another Guest") + ) + + assertEquals( + listOf("Primary Artist", "Guest Artist", "Another Guest"), + values + ) + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt index 58d8c857..a3992ee7 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt @@ -54,6 +54,8 @@ class MusicRepositoryImplTest { ) every { mockMusicDao.getAllArtistsRaw() } returns flowOf(dummyArtists) coEvery { mockMusicDao.getDistinctParentDirectories() } returns listOf("/music/folder1", "/music/folder2") + every { mockMusicDao.getDistinctParentDirectoriesFlow() } returns + flowOf(listOf("/music/folder1", "/music/folder2")) every { mockMusicDao.getAllSongArtistCrossRefs() } returns flowOf(emptyList()) every { mockMusicDao.getAllSongs(any(), any()) } answers { println("getAllSongs called with: ${args[0]}, ${args[1]}") diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtilsTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtilsTest.kt index 6fe9e1cb..494422d3 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtilsTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/worker/ArtistParsingUtilsTest.kt @@ -50,4 +50,48 @@ class ArtistParsingUtilsTest { result ) } + + @Test + fun `collectArtistNames splits the reporter artist string with configured delimiters`() { + val result = + collectArtistNames( + rawArtistName = "-M-, Toumani Diabaté, Sidiki Diabate, Fatoumata Diawara & Oxmo Puccino", + title = "Bal de Bamako", + artistDelimiters = listOf(",", "&"), + wordDelimiters = emptyList(), + extractFromTitle = false + ) + + assertEquals( + listOf( + "-M-", + "Toumani Diabaté", + "Sidiki Diabate", + "Fatoumata Diawara", + "Oxmo Puccino" + ), + result + ) + } + + @Test + fun `collectArtistNames preserves and splits every repeated artist tag value`() { + val result = + collectArtistNames( + rawArtistNames = listOf( + "Primary Artist", + "Guest Artist & Another Guest", + "primary artist" + ), + title = "Track", + artistDelimiters = listOf("&"), + wordDelimiters = emptyList(), + extractFromTitle = false + ) + + assertEquals( + listOf("Primary Artist", "Guest Artist", "Another Guest"), + result + ) + } } From a3ba64a96bcc5f66867bf019ddabb2c0d3d04e2a Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:02:10 +0300 Subject: [PATCH 03/18] Order album tracks by disc and track number (#99) Album sorting now keeps discs in sequence, places numbered tracks before unknown positions, and uses stable title and id fallbacks. --- .../data/database/MusicDaoTest.kt | 233 +++++++++++++++--- .../pixelplayeross/data/database/MusicDao.kt | 47 +++- 2 files changed, 246 insertions(+), 34 deletions(-) diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/MusicDaoTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/MusicDaoTest.kt index 73bec975..92399c27 100644 --- a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/MusicDaoTest.kt +++ b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/MusicDaoTest.kt @@ -1,6 +1,7 @@ package com.lostf1sh.pixelplayeross.data.database import android.content.Context +import androidx.paging.PagingSource import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -9,8 +10,6 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -38,14 +37,25 @@ class MusicDaoTest { db.close() } - private fun createSongEntity(id: Long, title: String, artist: String, album: String, path: String, genre: String = "Pop"): SongEntity { + private fun createSongEntity( + id: Long, + title: String, + artist: String, + album: String, + path: String, + genre: String = "Pop", + artistId: Long = 101L, + albumId: Long = 201L, + trackNumber: Int = 1, + discNumber: Int? = null + ): SongEntity { return SongEntity( id = id, title = title, artistName = artist, - artistId = 101L, + artistId = artistId, albumName = album, - albumId = 201L, + albumId = albumId, contentUriString = "uri_$id", albumArtUriString = "art_uri_$id", duration = 180000, @@ -53,16 +63,81 @@ class MusicDaoTest { filePath = path, parentDirectoryPath = path.substringBeforeLast("/"), year = 2023, - trackNumber = 1 + trackNumber = trackNumber, + discNumber = discNumber ) } - private fun createAlbumEntity(id: Long, title: String): AlbumEntity { + private suspend fun insertSongsWithParents(songs: List) { + val artists = songs + .distinctBy(SongEntity::artistId) + .map { song -> createArtistEntity(song.artistId, song.artistName) } + val albums = songs + .distinctBy(SongEntity::albumId) + .map { song -> + createAlbumEntity( + id = song.albumId, + title = song.albumName, + artistId = song.artistId, + artistName = song.artistName + ) + } + + musicDao.insertMusicData(songs, albums, artists) + } + + private suspend fun insertAlbumSortFixture(): Pair, List> { + musicDao.insertArtists(listOf(createArtistEntity(101L, "Artist"))) + musicDao.insertAlbums( + listOf( + createAlbumEntity(201L, "Alpha"), + createAlbumEntity(202L, "Beta") + ) + ) + + musicDao.insertSongs( + listOf( + createSongEntity(11L, "Zeta Track Two", "Artist", "Alpha", "/alpha/11.mp3", albumId = 201L, trackNumber = 2, discNumber = 1), + createSongEntity(12L, "Track One", "Artist", "Alpha", "/alpha/12.mp3", albumId = 201L, trackNumber = 1, discNumber = 1), + createSongEntity(13L, "Zulu Unknown", "Artist", "Alpha", "/alpha/13.mp3", albumId = 201L, trackNumber = 0, discNumber = 1), + createSongEntity(14L, "Disc Two", "Artist", "Alpha", "/alpha/14.mp3", albumId = 201L, trackNumber = 1, discNumber = 2), + createSongEntity(15L, "Track Three", "Artist", "Alpha", "/alpha/15.mp3", albumId = 201L, trackNumber = 3, discNumber = 0), + createSongEntity(16L, "Alpha Track Two", "Artist", "Alpha", "/alpha/16.mp3", albumId = 201L, trackNumber = 2, discNumber = null), + createSongEntity(17L, "Alpha Track Two", "Artist", "Alpha", "/alpha/17.mp3", albumId = 201L, trackNumber = 2, discNumber = null), + createSongEntity(18L, "Alpha Unknown", "Artist", "Alpha", "/alpha/18.mp3", albumId = 201L, trackNumber = -1, discNumber = 1), + createSongEntity(21L, "Beta Disc Two", "Artist", "Beta", "/beta/21.mp3", albumId = 202L, trackNumber = 1, discNumber = 2), + createSongEntity(22L, "Beta Track Two", "Artist", "Beta", "/beta/22.mp3", albumId = 202L, trackNumber = 2, discNumber = 1), + createSongEntity(23L, "Beta Track One", "Artist", "Beta", "/beta/23.mp3", albumId = 202L, trackNumber = 1, discNumber = 1) + ) + ) + + val alphaOrder = listOf(12L, 16L, 17L, 11L, 15L, 18L, 13L, 14L) + val betaOrder = listOf(23L, 22L, 21L) + return (alphaOrder + betaOrder) to (betaOrder + alphaOrder) + } + + private suspend fun PagingSource.loadIds(): List { + val result = load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 100, + placeholdersEnabled = false + ) + ) + return (result as PagingSource.LoadResult.Page).data.map(SongEntity::id) + } + + private fun createAlbumEntity( + id: Long, + title: String, + artistId: Long = 101L, + artistName: String = "Artist" + ): AlbumEntity { return AlbumEntity( id = id, title = title, - artistName = "Artist", - artistId = 101L, + artistName = artistName, + artistId = artistId, albumArtUriString = "art_uri_$id", songCount = 5, dateAdded = 0L, @@ -79,9 +154,18 @@ class MusicDaoTest { fun insertAndGetSongs() = runTest { val songList = listOf( createSongEntity(1L, "Song A", "Artist 1", "Album X", "/path/a/songA.mp3"), - createSongEntity(2L, "Song B", "Artist 2", "Album Y", "/path/b/songB.mp3", "Rock") + createSongEntity( + 2L, + "Song B", + "Artist 2", + "Album Y", + "/path/b/songB.mp3", + genre = "Rock", + artistId = 102L, + albumId = 202L + ) ) - musicDao.insertSongs(songList) + insertSongsWithParents(songList) val retrievedSongs = musicDao.getSongs(emptyList(), false).first() assertEquals(2, retrievedSongs.size) @@ -92,17 +176,16 @@ class MusicDaoTest { @Test @Throws(Exception::class) fun insertAndGetAlbums() = runTest { + val artists = listOf(createArtistEntity(101L, "Artist 1")) + val albums = listOf( + createAlbumEntity(201L, "Album X", artistName = "Artist 1"), + createAlbumEntity(202L, "Album Y", artistName = "Artist 1") + ) val songs = listOf( createSongEntity(1L, "Song A", "Artist 1", "Album X", "/path/a/songA.mp3"), createSongEntity(2L, "Song B", "Artist 1", "Album X", "/path/a/songB.mp3") ) - musicDao.insertSongs(songs) - - val albumList = listOf( - createAlbumEntity(201L, "Album X"), - createAlbumEntity(202L, "Album Y") - ) - musicDao.insertAlbums(albumList) + musicDao.insertMusicData(songs, albums, artists) val retrievedAlbums = musicDao.getAlbums(emptyList(), false, 0, 1).first() @@ -114,14 +197,15 @@ class MusicDaoTest { @Test @Throws(Exception::class) fun insertAndGetArtists() = runTest { - val song = createSongEntity(1L, "Song A", "Artist 1", "Album X", "/path/a/songA.mp3") - musicDao.insertSongs(listOf(song)) - - val artistList = listOf( + val artists = listOf( createArtistEntity(101L, "Artist 1"), createArtistEntity(102L, "Artist 2") ) - musicDao.insertArtists(artistList) + val albums = listOf(createAlbumEntity(201L, "Album X", artistName = "Artist 1")) + val songs = listOf( + createSongEntity(1L, "Song A", "Artist 1", "Album X", "/path/a/songA.mp3") + ) + musicDao.insertMusicData(songs, albums, artists) val retrievedArtists = musicDao.getArtists(emptyList(), false).first() assertEquals(1, retrievedArtists.size) @@ -130,9 +214,17 @@ class MusicDaoTest { @Test @Throws(Exception::class) - fun insertMusicData_clearsOldAndInsertsNew() = runTest { - val oldSong = createSongEntity(1L, "Old Song", "Old Artist", "Old Album", "/old/path/old.mp3") - musicDao.insertSongs(listOf(oldSong)) + fun insertMusicData_insertsNewWithoutClearingExisting() = runTest { + val oldSong = createSongEntity( + 1L, + "Old Song", + "Old Artist", + "Old Album", + "/old/path/old.mp3", + artistId = 1001L, + albumId = 2001L + ) + insertSongsWithParents(listOf(oldSong)) val songs = listOf( createSongEntity(10L, "Song A", "Artist 1", "Album X", "/path/a/songA.mp3") @@ -147,13 +239,10 @@ class MusicDaoTest { musicDao.insertMusicData(songs, albums, artists) val oldSongRetrieved = musicDao.getSongById(1L).first() - assertNull(oldSongRetrieved) + assertNotNull(oldSongRetrieved) val newSongRetrieved = musicDao.getSongById(10L).first() assertNotNull(newSongRetrieved) - - val oldSongStillThere = musicDao.getSongById(1L).first() - assertNotNull(oldSongStillThere) } @Test @@ -161,14 +250,92 @@ class MusicDaoTest { fun searchSongs_returnsMatchingSongs() = runTest { val songs = listOf( createSongEntity(1L, "Cool Song", "Artist A", "Album X", "/p1/s1.mp3"), - createSongEntity(2L, "Another Song", "Artist B", "Album Y", "/p2/s2.mp3", "Rock"), - createSongEntity(3L, "Coolest Song Ever", "Artist C", "Album Z", "/p3/s3.mp3") + createSongEntity( + 2L, + "Another Song", + "Artist B", + "Album Y", + "/p2/s2.mp3", + genre = "Rock", + artistId = 102L, + albumId = 202L + ), + createSongEntity( + 3L, + "Coolest Song Ever", + "Artist C", + "Album Z", + "/p3/s3.mp3", + artistId = 103L, + albumId = 203L + ) ) - musicDao.insertSongs(songs) + insertSongsWithParents(songs) val results = musicDao.searchSongs("Cool", emptyList(), false).first() assertEquals(2, results.size) val titles = results.map { it.title }.sorted() assertEquals(listOf("Cool Song", "Coolest Song Ever"), titles) } + + @Test + fun albumSort_ordersEveryLibraryQueryByDiscAndTrackWithinAlbum() = runTest { + val (ascending, descending) = insertAlbumSortFixture() + + listOf( + "song_album" to ascending, + "song_album_desc" to descending + ).forEach { (sortOrder, expectedIds) -> + assertEquals( + expectedIds, + musicDao.getSongIdsSorted(emptyList(), false, sortOrder, 0) + ) + assertEquals( + expectedIds, + musicDao.getSongsPage(emptyList(), false, sortOrder, 0, 100, 0).map(SongEntity::id) + ) + assertEquals( + expectedIds, + musicDao.getSongsPaginated(emptyList(), false, sortOrder, 0).loadIds() + ) + } + } + + @Test + fun albumSort_ordersEveryFavoriteQueryByDiscAndTrackWithinAlbum() = runTest { + val (ascending, descending) = insertAlbumSortFixture() + db.favoritesDao().insertAll( + (ascending + descending) + .distinct() + .map { songId -> FavoritesEntity(songId = songId, timestamp = songId) } + ) + + listOf( + "liked_album" to ascending, + "liked_album_desc" to descending + ).forEach { (sortOrder, expectedIds) -> + assertEquals( + expectedIds, + musicDao.getFavoriteSongIdsSorted(emptyList(), false, sortOrder, 0) + ) + assertEquals( + expectedIds, + musicDao.getFavoriteSongsPage(emptyList(), false, sortOrder, 0, 100, 0).map(SongEntity::id) + ) + assertEquals( + expectedIds, + musicDao.getFavoriteSongsPaginated(emptyList(), false, sortOrder, 0).loadIds() + ) + } + } + + @Test + fun albumDetail_ordersUnknownDiscAsDiscOneAndUnknownTracksLast() = runTest { + val (ascending, _) = insertAlbumSortFixture() + + assertEquals( + ascending.take(8), + musicDao.getSongsByAlbumId(201L).first().map(SongEntity::id) + ) + } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt index 55f31270..9ef3b4bb 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt @@ -424,7 +424,16 @@ interface MusicDao { applyDirectoryFilter: Boolean ): Flow> - @Query("SELECT * FROM songs WHERE album_id = :albumId ORDER BY disc_number ASC, track_number ASC") + @Query(""" + SELECT * FROM songs + WHERE album_id = :albumId + ORDER BY + CASE WHEN COALESCE(disc_number, 0) <= 0 THEN 1 ELSE disc_number END ASC, + CASE WHEN track_number > 0 THEN 0 ELSE 1 END ASC, + CASE WHEN track_number > 0 THEN track_number END ASC, + title COLLATE NOCASE ASC, + id ASC + """) fun getSongsByAlbumId(albumId: Long): Flow> @Query("SELECT * FROM songs WHERE artist_id = :artistId ORDER BY title ASC") @@ -646,6 +655,12 @@ interface MusicDao { CASE WHEN :sortOrder = 'song_artist_desc' THEN artist_name END COLLATE NOCASE DESC, CASE WHEN :sortOrder = 'song_album' THEN album_name END COLLATE NOCASE ASC, CASE WHEN :sortOrder = 'song_album_desc' THEN album_name END COLLATE NOCASE DESC, + CASE WHEN :sortOrder IN ('song_album', 'song_album_desc') + THEN CASE WHEN COALESCE(disc_number, 0) <= 0 THEN 1 ELSE disc_number END END ASC, + CASE WHEN :sortOrder IN ('song_album', 'song_album_desc') + THEN CASE WHEN track_number > 0 THEN 0 ELSE 1 END END ASC, + CASE WHEN :sortOrder IN ('song_album', 'song_album_desc') AND track_number > 0 + THEN track_number END ASC, CASE WHEN :sortOrder = 'song_date_added' THEN date_added END DESC, CASE WHEN :sortOrder = 'song_date_added_asc' THEN date_added END ASC, CASE WHEN :sortOrder = 'song_duration' THEN duration END DESC, @@ -682,6 +697,12 @@ interface MusicDao { CASE WHEN :sortOrder = 'liked_artist_desc' THEN songs.artist_name END COLLATE NOCASE DESC, CASE WHEN :sortOrder = 'liked_album' THEN songs.album_name END COLLATE NOCASE ASC, CASE WHEN :sortOrder = 'liked_album_desc' THEN songs.album_name END COLLATE NOCASE DESC, + CASE WHEN :sortOrder IN ('liked_album', 'liked_album_desc') + THEN CASE WHEN COALESCE(songs.disc_number, 0) <= 0 THEN 1 ELSE songs.disc_number END END ASC, + CASE WHEN :sortOrder IN ('liked_album', 'liked_album_desc') + THEN CASE WHEN songs.track_number > 0 THEN 0 ELSE 1 END END ASC, + CASE WHEN :sortOrder IN ('liked_album', 'liked_album_desc') AND songs.track_number > 0 + THEN songs.track_number END ASC, CASE WHEN :sortOrder = 'liked_date_liked' THEN favorites.timestamp END DESC, CASE WHEN :sortOrder = 'liked_date_liked_asc' THEN favorites.timestamp END ASC, songs.title COLLATE NOCASE ASC, @@ -720,6 +741,12 @@ interface MusicDao { CASE WHEN :sortOrder = 'song_artist_desc' THEN artist_name END COLLATE NOCASE DESC, CASE WHEN :sortOrder = 'song_album' THEN album_name END COLLATE NOCASE ASC, CASE WHEN :sortOrder = 'song_album_desc' THEN album_name END COLLATE NOCASE DESC, + CASE WHEN :sortOrder IN ('song_album', 'song_album_desc') + THEN CASE WHEN COALESCE(disc_number, 0) <= 0 THEN 1 ELSE disc_number END END ASC, + CASE WHEN :sortOrder IN ('song_album', 'song_album_desc') + THEN CASE WHEN track_number > 0 THEN 0 ELSE 1 END END ASC, + CASE WHEN :sortOrder IN ('song_album', 'song_album_desc') AND track_number > 0 + THEN track_number END ASC, CASE WHEN :sortOrder = 'song_date_added' THEN date_added END DESC, CASE WHEN :sortOrder = 'song_date_added_asc' THEN date_added END ASC, CASE WHEN :sortOrder = 'song_duration' THEN duration END DESC, @@ -759,6 +786,12 @@ interface MusicDao { CASE WHEN :sortOrder = 'song_artist_desc' THEN artist_name END COLLATE NOCASE DESC, CASE WHEN :sortOrder = 'song_album' THEN album_name END COLLATE NOCASE ASC, CASE WHEN :sortOrder = 'song_album_desc' THEN album_name END COLLATE NOCASE DESC, + CASE WHEN :sortOrder IN ('song_album', 'song_album_desc') + THEN CASE WHEN COALESCE(disc_number, 0) <= 0 THEN 1 ELSE disc_number END END ASC, + CASE WHEN :sortOrder IN ('song_album', 'song_album_desc') + THEN CASE WHEN track_number > 0 THEN 0 ELSE 1 END END ASC, + CASE WHEN :sortOrder IN ('song_album', 'song_album_desc') AND track_number > 0 + THEN track_number END ASC, CASE WHEN :sortOrder = 'song_date_added' THEN date_added END DESC, CASE WHEN :sortOrder = 'song_date_added_asc' THEN date_added END ASC, CASE WHEN :sortOrder = 'song_duration' THEN duration END DESC, @@ -802,6 +835,12 @@ interface MusicDao { CASE WHEN :sortOrder = 'liked_artist_desc' THEN songs.artist_name END COLLATE NOCASE DESC, CASE WHEN :sortOrder = 'liked_album' THEN songs.album_name END COLLATE NOCASE ASC, CASE WHEN :sortOrder = 'liked_album_desc' THEN songs.album_name END COLLATE NOCASE DESC, + CASE WHEN :sortOrder IN ('liked_album', 'liked_album_desc') + THEN CASE WHEN COALESCE(songs.disc_number, 0) <= 0 THEN 1 ELSE songs.disc_number END END ASC, + CASE WHEN :sortOrder IN ('liked_album', 'liked_album_desc') + THEN CASE WHEN songs.track_number > 0 THEN 0 ELSE 1 END END ASC, + CASE WHEN :sortOrder IN ('liked_album', 'liked_album_desc') AND songs.track_number > 0 + THEN songs.track_number END ASC, CASE WHEN :sortOrder = 'liked_date_liked' THEN favorites.timestamp END DESC, CASE WHEN :sortOrder = 'liked_date_liked_asc' THEN favorites.timestamp END ASC, songs.title COLLATE NOCASE ASC, @@ -863,6 +902,12 @@ interface MusicDao { CASE WHEN :sortOrder = 'liked_artist_desc' THEN songs.artist_name END COLLATE NOCASE DESC, CASE WHEN :sortOrder = 'liked_album' THEN songs.album_name END COLLATE NOCASE ASC, CASE WHEN :sortOrder = 'liked_album_desc' THEN songs.album_name END COLLATE NOCASE DESC, + CASE WHEN :sortOrder IN ('liked_album', 'liked_album_desc') + THEN CASE WHEN COALESCE(songs.disc_number, 0) <= 0 THEN 1 ELSE songs.disc_number END END ASC, + CASE WHEN :sortOrder IN ('liked_album', 'liked_album_desc') + THEN CASE WHEN songs.track_number > 0 THEN 0 ELSE 1 END END ASC, + CASE WHEN :sortOrder IN ('liked_album', 'liked_album_desc') AND songs.track_number > 0 + THEN songs.track_number END ASC, CASE WHEN :sortOrder = 'liked_date_liked' THEN favorites.timestamp END DESC, CASE WHEN :sortOrder = 'liked_date_liked_asc' THEN favorites.timestamp END ASC, songs.title COLLATE NOCASE ASC, From 97677daddb065cd72ba7bbb8042b2027e7ef5b5a Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:04:16 +0300 Subject: [PATCH 04/18] Keep the screen awake during playback (#100) A searchable playback setting now keeps the visible activity awake only while music is actively playing and always clears the window flag afterward. --- .../lostf1sh/pixelplayeross/MainActivity.kt | 27 +++++++++++++++++++ .../preferences/UserPreferencesRepository.kt | 13 +++++++++ .../screens/SettingsCategoryScreen.kt | 19 +++++++++++++ .../settings/search/SettingsRegistry.kt | 16 +++++++++++ .../viewmodel/SettingsViewModel.kt | 13 +++++++++ .../pixelplayeross/utils/ScreenAwakePolicy.kt | 6 +++++ .../main/res/values-tr/strings_settings.xml | 2 ++ app/src/main/res/values/strings_settings.xml | 2 ++ .../UserPreferencesRepositoryTest.kt | 22 +++++++++++++++ .../utils/ScreenAwakePolicyTest.kt | 15 +++++++++++ 10 files changed, 135 insertions(+) create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/utils/ScreenAwakePolicy.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/utils/ScreenAwakePolicyTest.kt diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/MainActivity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/MainActivity.kt index 98db6f11..485b2bff 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/MainActivity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/MainActivity.kt @@ -10,6 +10,7 @@ import android.os.Build import android.os.Bundle import com.lostf1sh.pixelplayeross.utils.traceSection import android.provider.Settings +import android.view.WindowManager import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle @@ -65,6 +66,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -134,6 +137,7 @@ import com.lostf1sh.pixelplayeross.ui.theme.PixelPlayerTheme import com.lostf1sh.pixelplayeross.utils.AppLocaleManager import com.lostf1sh.pixelplayeross.utils.CrashHandler import com.lostf1sh.pixelplayeross.utils.LogUtils +import com.lostf1sh.pixelplayeross.utils.shouldKeepScreenAwake import dagger.hilt.android.AndroidEntryPoint import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay @@ -148,6 +152,7 @@ import com.lostf1sh.pixelplayeross.presentation.utils.NoOpHapticFeedback import com.lostf1sh.pixelplayeross.utils.CrashLogData import javax.annotation.concurrent.Immutable import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map @@ -208,6 +213,28 @@ class MainActivity : ComponentActivity() { } super.onCreate(savedInstanceState) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + try { + combine( + userPreferencesRepository.keepScreenAwakeWhilePlayingFlow, + playerViewModel.stablePlayerState + .map { state -> state.isPlaying } + .distinctUntilChanged(), + ::shouldKeepScreenAwake, + ).distinctUntilChanged().collect { keepAwake -> + if (keepAwake) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } finally { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + splashScreen.setKeepOnScreenCondition { false } val isBenchmarkMode = intent.getBooleanExtra("is_benchmark", false) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt index 3a5785fd..f7c4e1fe 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt @@ -140,6 +140,8 @@ constructor( val FOLDER_BACK_GESTURE_NAVIGATION = booleanPreferencesKey("folder_back_gesture_navigation") val USE_SMOOTH_CORNERS = booleanPreferencesKey("use_smooth_corners") val KEEP_PLAYING_IN_BACKGROUND = booleanPreferencesKey("keep_playing_in_background") + val KEEP_SCREEN_AWAKE_WHILE_PLAYING = + booleanPreferencesKey("keep_screen_awake_while_playing") val IS_CROSSFADE_ENABLED = booleanPreferencesKey("is_crossfade_enabled") val AUDIO_OUTPUT_MODE = stringPreferencesKey("audio_output_mode_v1") val SMART_CROSSFADE_ENABLED = booleanPreferencesKey("smart_crossfade_enabled") @@ -820,6 +822,11 @@ constructor( preferences[PreferencesKeys.KEEP_PLAYING_IN_BACKGROUND] ?: true } + val keepScreenAwakeWhilePlayingFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.KEEP_SCREEN_AWAKE_WHILE_PLAYING] ?: false + }.distinctUntilChanged() + val resumeOnHeadsetReconnectFlow: Flow = dataStore.data.map { preferences -> preferences[PreferencesKeys.RESUME_ON_HEADSET_RECONNECT] ?: false @@ -1290,6 +1297,12 @@ constructor( } } + suspend fun setKeepScreenAwakeWhilePlaying(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.KEEP_SCREEN_AWAKE_WHILE_PLAYING] = enabled + } + } + suspend fun setResumeOnHeadsetReconnect(enabled: Boolean) { dataStore.edit { preferences -> preferences[PreferencesKeys.RESUME_ON_HEADSET_RECONNECT] = enabled diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt index 6f09b183..82634630 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt @@ -761,6 +761,25 @@ fun SettingsCategoryScreen( leadingIcon = { Icon(Icons.Rounded.MusicNote, null, tint = MaterialTheme.colorScheme.secondary) }, modifier = Modifier.settingHighlight("item_playback_keep_playing", highlightKey) ) + SwitchSettingItem( + title = stringResource(R.string.setcat_keep_screen_awake_title), + subtitle = stringResource(R.string.setcat_keep_screen_awake_subtitle), + checked = uiState.keepScreenAwakeWhilePlaying, + onCheckedChange = { + settingsViewModel.setKeepScreenAwakeWhilePlaying(it) + }, + leadingIcon = { + Icon( + Icons.Outlined.LightMode, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + ) + }, + modifier = Modifier.settingHighlight( + "item_playback_keep_screen_awake", + highlightKey, + ), + ) } SettingsSubsection(title = stringResource(R.string.setcat_replaygain_section)) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt index ca09e594..f53cdb42 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt @@ -337,6 +337,22 @@ object SettingsRegistry { getValue = { it.keepPlayingInBackground }, onToggle = { viewModel, checked -> viewModel.setKeepPlayingInBackground(checked) } ), + SettingSpec( + id = "playback_keep_screen_awake", + itemKey = "item_playback_keep_screen_awake", + titleRes = R.string.setcat_keep_screen_awake_title, + subtitleRes = R.string.setcat_keep_screen_awake_subtitle, + category = SettingsCategory.PLAYBACK, + subscreenRoute = Screen.SettingsCategory.createRoute("playback"), + type = SettingType.SWITCH, + keywordsStatic = listOf( + "screen", "awake", "display", "driving", "sleep", "lock" + ), + getValue = { it.keepScreenAwakeWhilePlaying }, + onToggle = { viewModel, checked -> + viewModel.setKeepScreenAwakeWhilePlaying(checked) + } + ), SettingSpec( id = "playback_replaygain", itemKey = "item_playback_replaygain", diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt index 3e7647d2..7de20a1b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt @@ -68,6 +68,7 @@ data class SettingsUiState( val libraryNavigationMode: String = LibraryNavigationMode.TAB_ROW, val launchTab: String = LaunchTab.HOME, val keepPlayingInBackground: Boolean = true, + val keepScreenAwakeWhilePlaying: Boolean = false, val resumeOnHeadsetReconnect: Boolean = false, val showQueueHistory: Boolean = true, val isCrossfadeEnabled: Boolean = false, @@ -374,6 +375,12 @@ class SettingsViewModel @Inject constructor( } } + viewModelScope.launch { + userPreferencesRepository.keepScreenAwakeWhilePlayingFlow.collect { enabled -> + _uiState.update { it.copy(keepScreenAwakeWhilePlaying = enabled) } + } + } + viewModelScope.launch { userPreferencesRepository.fullPlayerLoadingTweaksFlow.collect { tweaks -> _uiState.update { it.copy(fullPlayerLoadingTweaks = tweaks) } @@ -606,6 +613,12 @@ class SettingsViewModel @Inject constructor( } } + fun setKeepScreenAwakeWhilePlaying(enabled: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setKeepScreenAwakeWhilePlaying(enabled) + } + } + fun setResumeOnHeadsetReconnect(enabled: Boolean) { viewModelScope.launch { userPreferencesRepository.setResumeOnHeadsetReconnect(enabled) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/ScreenAwakePolicy.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/ScreenAwakePolicy.kt new file mode 100644 index 00000000..1c02e30d --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/ScreenAwakePolicy.kt @@ -0,0 +1,6 @@ +package com.lostf1sh.pixelplayeross.utils + +internal fun shouldKeepScreenAwake( + preferenceEnabled: Boolean, + isPlaying: Boolean, +): Boolean = preferenceEnabled && isPlaying diff --git a/app/src/main/res/values-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml index afb269a3..1f1f7471 100644 --- a/app/src/main/res/values-tr/strings_settings.xml +++ b/app/src/main/res/values-tr/strings_settings.xml @@ -141,6 +141,8 @@ Yedeği İçe Aktar Kapalıysa, uygulama son kullanılanlardan kaldırıldığında oynatma durdurulur. Kapatıldıktan sonra oynatmaya devam et + Oynatırken ekranı açık tut + Müzik çalarken ve uygulama görünürken ekranın uykuya geçmesini önler. Sistem varsayılanı Kompakt buton ve ızgara Sekme satırı (varsayılan) diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 3dd67124..92815da2 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -137,6 +137,8 @@ Background Playback Keep playing after closing If off, removing the app from recents will stop playback. + Keep screen awake while playing + Prevents the screen from sleeping while music is actively playing and the app is visible. Volume Normalization (ReplayGain) Enable ReplayGain Normalize volume levels using ReplayGain metadata from audio files. diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepositoryTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepositoryTest.kt index 0c79b771..25892654 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepositoryTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepositoryTest.kt @@ -11,6 +11,28 @@ import java.nio.file.Files class UserPreferencesRepositoryTest { + @Test + fun `keep screen awake while playing is opt-in and persists`() = runTest { + val tempDir = Files.createTempDirectory("user-preferences-repository-test") + try { + val repository = UserPreferencesRepository( + dataStore = PreferenceDataStoreFactory.create( + scope = backgroundScope, + produceFile = { tempDir.resolve("settings.preferences_pb").toFile() } + ), + json = Json + ) + + assertEquals(false, repository.keepScreenAwakeWhilePlayingFlow.first()) + + repository.setKeepScreenAwakeWhilePlaying(true) + + assertTrue(repository.keepScreenAwakeWhilePlayingFlow.first()) + } finally { + tempDir.toFile().deleteRecursively() + } + } + @Test fun `clearPreferencesExceptKeys preserves initial setup completion`() = runTest { val tempDir = Files.createTempDirectory("user-preferences-repository-test") diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/ScreenAwakePolicyTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/ScreenAwakePolicyTest.kt new file mode 100644 index 00000000..e755738c --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/ScreenAwakePolicyTest.kt @@ -0,0 +1,15 @@ +package com.lostf1sh.pixelplayeross.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ScreenAwakePolicyTest { + + @Test + fun `screen stays awake only while enabled playback is active`() { + assertThat(shouldKeepScreenAwake(preferenceEnabled = true, isPlaying = true)).isTrue() + assertThat(shouldKeepScreenAwake(preferenceEnabled = true, isPlaying = false)).isFalse() + assertThat(shouldKeepScreenAwake(preferenceEnabled = false, isPlaying = true)).isFalse() + assertThat(shouldKeepScreenAwake(preferenceEnabled = false, isPlaying = false)).isFalse() + } +} From 7f6cbb77f7f57ca12ab7c1178b2aadec7ec22328 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:04:27 +0300 Subject: [PATCH 05/18] Support responsive word-synced lyrics (#104) Enhanced lyric files are parsed into word timings and rendered with faster, continuous highlighting while line-synced and plain lyrics keep their existing behavior. --- .../data/network/lyrics/LrcLibResponse.kt | 13 +- .../data/network/lyrics/LyricsfileParser.kt | 125 +++++++++++ .../data/repository/LyricsRepositoryImpl.kt | 26 ++- .../presentation/components/LyricsSheet.kt | 212 +++++++++++++----- .../components/UnifiedPlayerOverlaysLayer.kt | 5 +- .../components/UnifiedPlayerSheetV2.kt | 4 +- .../components/subcomps/FetchLyricsDialog.kt | 2 +- .../network/lyrics/LyricsfileParserTest.kt | 69 ++++++ .../repository/LyricsRepositoryImplTest.kt | 59 ++++- .../components/LyricsSheetLogicTest.kt | 114 ++++++++++ 10 files changed, 549 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LyricsfileParser.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LyricsfileParserTest.kt diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LrcLibResponse.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LrcLibResponse.kt index 8e299d2e..4f6cbb2b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LrcLibResponse.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LrcLibResponse.kt @@ -13,5 +13,14 @@ data class LrcLibResponse( @SerializedName("albumName") val albumName: String, @SerializedName("duration") val duration: Double, @SerializedName("plainLyrics") val plainLyrics: String?, - @SerializedName("syncedLyrics") val syncedLyrics: String? -) \ No newline at end of file + @SerializedName("syncedLyrics") val syncedLyrics: String?, + @SerializedName("lyricsfile") val lyricsfile: String? = null, +) { + val hasSyncedContent: Boolean + get() = !lyricsfile.isNullOrBlank() || !syncedLyrics.isNullOrBlank() + + internal fun preferredLyricsContent(): String? = + LyricsfileParser.toEnhancedLrc(lyricsfile) + ?: syncedLyrics?.takeIf { it.isNotBlank() } + ?: plainLyrics?.takeIf { it.isNotBlank() } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LyricsfileParser.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LyricsfileParser.kt new file mode 100644 index 00000000..247fbaab --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LyricsfileParser.kt @@ -0,0 +1,125 @@ +package com.lostf1sh.pixelplayeross.data.network.lyrics + +import java.util.Locale +import org.yaml.snakeyaml.LoaderOptions +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.constructor.SafeConstructor + +/** Converts LRCLIB's open Lyricsfile YAML payload into the enhanced-LRC format we persist. */ +internal object LyricsfileParser { + private const val MAX_LYRICSFILE_CODE_POINTS = 1_000_000 + private const val MAX_LINES = 10_000 + private const val MAX_WORDS_PER_LINE = 2_000 + + private fun newYaml(): Yaml { + val options = LoaderOptions().apply { + codePointLimit = MAX_LYRICSFILE_CODE_POINTS + maxAliasesForCollections = 0 + nestingDepthLimit = 20 + isAllowDuplicateKeys = false + } + return Yaml(SafeConstructor(options)) + } + + fun toEnhancedLrc(rawLyricsfile: String?): String? { + val source = rawLyricsfile?.takeIf { it.isNotBlank() } ?: return null + val root = runCatching { newYaml().load(source) }.getOrNull() as? Map<*, *> + ?: return null + // Lyricsfile is still a draft. Refuse unknown versions instead of silently + // interpreting a future, potentially incompatible schema as version 1.0. + if (root["version"]?.toString() != "1.0") return null + val lines = (root["lines"] as? List<*>) + ?.take(MAX_LINES) + .orEmpty() + + return lines.mapNotNull(::parseLine) + .takeIf { it.isNotEmpty() } + ?.joinToString("\n") + } + + private fun parseLine(rawLine: Any?): String? { + val line = rawLine as? Map<*, *> ?: return null + val text = line["text"]?.toString()?.sanitizeLineText().orEmpty() + val rawWords = (line["words"] as? List<*>) + ?.take(MAX_WORDS_PER_LINE) + .orEmpty() + val words = rawWords.mapNotNull(::parseWord) + val startMs = line["start_ms"].asMilliseconds() + ?: words.firstOrNull()?.startMs + ?: return null + + val content = if (words.isNotEmpty()) { + buildWordSyncedContent(text, words) + } else { + text + } + if (content.isBlank()) return null + return "[${formatTimestamp(startMs)}]$content" + } + + /** + * Lyricsfile's line text is canonical; word entries from some providers omit punctuation or + * surrounding spaces. Place timing tags at the matching word positions in that canonical text + * so enhanced-LRC playback keeps both timing and the exact visible lyric. + */ + private fun buildWordSyncedContent(text: String, words: List): String { + if (text.isNotBlank()) { + val aligned = StringBuilder() + var cursor = 0 + var allWordsAligned = true + for (word in words) { + val token = word.text.sanitizeLineText().trim() + if (token.isEmpty()) continue + val position = text.indexOf(token, startIndex = cursor) + if (position < cursor) { + allWordsAligned = false + break + } + aligned.append(text, cursor, position) + aligned.append('<').append(formatTimestamp(word.startMs)).append('>') + aligned.append(token) + cursor = position + token.length + } + if (allWordsAligned && aligned.isNotEmpty()) { + aligned.append(text, cursor, text.length) + return aligned.toString() + } + } + + return words.joinToString(separator = "") { word -> + "<${formatTimestamp(word.startMs)}>${word.text.sanitizeLineText()}" + } + } + + private fun parseWord(rawWord: Any?): TimedWord? { + val word = rawWord as? Map<*, *> ?: return null + val text = word["text"]?.toString()?.takeIf { it.isNotEmpty() } ?: return null + val startMs = word["start_ms"].asMilliseconds() ?: return null + return TimedWord(startMs = startMs, text = text) + } + + private fun Any?.asMilliseconds(): Int? { + val value = when (this) { + is Number -> toLong() + is String -> toLongOrNull() + else -> null + } ?: return null + return value.takeIf { it in 0..Int.MAX_VALUE.toLong() }?.toInt() + } + + private fun String.sanitizeLineText(): String = + replace('\r', ' ').replace('\n', ' ') + + private fun formatTimestamp(timeMs: Int): String { + val totalSeconds = timeMs / 1_000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + val hundredths = (timeMs % 1_000) / 10 + return String.format(Locale.US, "%02d:%02d.%02d", minutes, seconds, hundredths) + } + + private data class TimedWord( + val startMs: Int, + val text: String, + ) +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/LyricsRepositoryImpl.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/LyricsRepositoryImpl.kt index cf0c229d..8edc0bb5 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/LyricsRepositoryImpl.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/LyricsRepositoryImpl.kt @@ -496,18 +496,22 @@ class LyricsRepositoryImpl @Inject constructor( ).firstOrNull()?.response if (bestMatch != null) { - val rawLyrics = bestMatch.syncedLyrics ?: bestMatch.plainLyrics + val rawLyrics = bestMatch.preferredLyricsContent() if (!rawLyrics.isNullOrBlank()) { val parsedLyrics = LyricsUtils.parseLyrics(rawLyrics).copy(areFromRemote = true) if (parsedLyrics.isValid()) { - Timber.tag(TAG).d("LRCLIB lyrics found - Synced: ${!bestMatch.syncedLyrics.isNullOrBlank()}, Plain: ${!bestMatch.plainLyrics.isNullOrBlank()}") + Timber.tag(TAG).d( + "LRCLIB lyrics found - Word synced: ${!bestMatch.lyricsfile.isNullOrBlank()}, " + + "Synced: ${bestMatch.hasSyncedContent}, " + + "Plain: ${!bestMatch.plainLyrics.isNullOrBlank()}" + ) try { lyricsDao.insert( com.lostf1sh.pixelplayeross.data.database.LyricsEntity( songId = song.id.toLong(), content = rawLyrics, - isSynced = !bestMatch.syncedLyrics.isNullOrBlank(), + isSynced = !parsedLyrics.synced.isNullOrEmpty(), source = "remote" ) ) @@ -528,10 +532,10 @@ class LyricsRepositoryImpl @Inject constructor( } private fun hasLyrics(response: LrcLibResponse): Boolean = - !response.plainLyrics.isNullOrBlank() || !response.syncedLyrics.isNullOrBlank() + !response.plainLyrics.isNullOrBlank() || response.hasSyncedContent private fun hasSyncedLyrics(response: LrcLibResponse): Boolean = - !response.syncedLyrics.isNullOrBlank() + response.hasSyncedContent private fun rankRemoteLyricsMatches( song: Song, @@ -1123,7 +1127,7 @@ class LyricsRepositoryImpl @Inject constructor( ?.let { rankRemoteLyricsMatches(song, listOf(it), RemoteLyricsMatchMode.AUTOMATIC).firstOrNull()?.response } if (exactMatch != null) { - val rawLyricsToSave = exactMatch.syncedLyrics ?: exactMatch.plainLyrics + val rawLyricsToSave = exactMatch.preferredLyricsContent() ?: return@withContext Result.failure(NoLyricsFoundException()) val parsedLyrics = LyricsUtils.parseLyrics(rawLyricsToSave).copy(areFromRemote = true) @@ -1212,19 +1216,19 @@ class LyricsRepositoryImpl @Inject constructor( ) val results = rankedMatches.mapNotNull { match -> val response = match.response - val rawLyrics = response.syncedLyrics ?: response.plainLyrics ?: return@mapNotNull null + val rawLyrics = response.preferredLyricsContent() ?: return@mapNotNull null val parsedLyrics = LyricsUtils.parseLyrics(rawLyrics).copy(areFromRemote = true) if (!parsedLyrics.isValid()) { LogUtils.w(this@LyricsRepositoryImpl, "Parsed lyrics are empty for: ${song.title}") return@mapNotNull null } - val hasSynced = !response.syncedLyrics.isNullOrEmpty() + val hasSynced = response.hasSyncedContent LogUtils.d(this@LyricsRepositoryImpl, " Found: ${response.name} by ${response.artistName} (synced: $hasSynced)") LyricsSearchResult(response, parsedLyrics, rawLyrics) } if (results.isNotEmpty()) { - val syncedCount = results.count { !it.record.syncedLyrics.isNullOrEmpty() } + val syncedCount = results.count { it.record.hasSyncedContent } LogUtils.d(this@LyricsRepositoryImpl, "Found ${results.size} lyrics for: ${song.title} ($syncedCount with synced)") Result.success(Pair(combinedQuery, results)) } else { @@ -1280,12 +1284,12 @@ class LyricsRepositoryImpl @Inject constructor( } val results = responses.mapNotNull { response -> - val rawLyrics = response.syncedLyrics ?: response.plainLyrics ?: return@mapNotNull null + val rawLyrics = response.preferredLyricsContent() ?: return@mapNotNull null val parsed = LyricsUtils.parseLyrics(rawLyrics).copy(areFromRemote = true) if (!parsed.isValid()) return@mapNotNull null LyricsSearchResult(response, parsed, rawLyrics) - }.sortedByDescending { !it.record.syncedLyrics.isNullOrEmpty() } + }.sortedByDescending { it.record.hasSyncedContent } if (results.isEmpty()) { Result.failure(NoLyricsFoundException(query)) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/LyricsSheet.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/LyricsSheet.kt index 28f2d75c..dbf5670f 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/LyricsSheet.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/LyricsSheet.kt @@ -1,5 +1,6 @@ package com.lostf1sh.pixelplayeross.presentation.components +import android.animation.ValueAnimator import android.widget.Toast import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.model.Lyrics @@ -71,6 +72,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.input.pointer.pointerInput @@ -123,12 +125,14 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.lostf1sh.pixelplayeross.data.preferences.dataStore import kotlin.math.abs import kotlin.math.pow import kotlin.math.roundToInt +import kotlin.math.roundToLong import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FilledTonalIconButton @@ -330,6 +334,11 @@ fun LyricsSheet( } val useAnimatedLyrics by useAnimatedLyricsFlow.collectAsStateWithLifecycle(initialValue = false) + val playbackSpeedFlow = remember(context) { + context.dataStore.data.map { it[floatPreferencesKey("playback_speed")] ?: 1f } + } + val playbackSpeed by playbackSpeedFlow.collectAsStateWithLifecycle(initialValue = 1f) + val animatedLyricsBlurEnabledFlow = remember(context) { context.dataStore.data.map { it[booleanPreferencesKey("animated_lyrics_blur_enabled")] ?: true } } @@ -738,6 +747,8 @@ fun LyricsSheet( lines = syncedLines, listState = syncedListState, playbackPositionFlow = playbackPositionFlow, + isPlaying = isPlaying, + playbackSpeed = playbackSpeed, lyricsSyncOffset = lyricsSyncOffset, positionOverrideMs = previewSeekPositionMs, accentColor = lyricHighlightColor, @@ -1141,6 +1152,8 @@ fun SyncedLyricsList( lines: ImmutableList, listState: LazyListState, playbackPositionFlow: StateFlow, + isPlaying: Boolean, + playbackSpeed: Float, lyricsSyncOffset: Int, positionOverrideMs: Long? = null, accentColor: Color, @@ -1161,7 +1174,14 @@ fun SyncedLyricsList( footer: LazyListScope.() -> Unit = {} ) { val density = LocalDensity.current - val playbackPosition by playbackPositionFlow.collectAsStateWithLifecycle() + val sampledPlaybackPosition by playbackPositionFlow.collectAsStateWithLifecycle() + val hasWordTimings = remember(lines) { lines.any { !it.words.isNullOrEmpty() } } + val playbackPosition = rememberInterpolatedPlaybackPosition( + sampledPositionMs = sampledPlaybackPosition, + isPlaying = isPlaying && hasWordTimings, + playbackSpeed = playbackSpeed, + positionOverrideMs = positionOverrideMs + ) val position = remember(playbackPosition, lyricsSyncOffset, positionOverrideMs) { positionOverrideMs ?: (playbackPosition + lyricsSyncOffset).coerceAtLeast(0L) } @@ -1356,61 +1376,49 @@ fun LyricLineRow( val isCurrentLine by remember(position, line.time, lineEndTime) { derivedStateOf { position in line.time.toLong().. if (immersiveMode) 1.02f else 1.1f; 1 -> 0.95f; else -> 0.85f + val targetScale = if (decorativeMotionEnabled) when (distanceFromCurrent) { + 0 -> if (immersiveMode) 1.02f else 1.07f; 1 -> 0.97f; else -> 0.92f } else 1f - val targetPadding = if (useAnimatedLyrics) when (distanceFromCurrent) { - 0 -> 32.dp; 1 -> 16.dp; else -> 8.dp - } else 12.dp - val targetAlpha = if (useAnimatedLyrics) when (distanceFromCurrent) { + val targetAlpha = if (decorativeMotionEnabled) when (distanceFromCurrent) { 0 -> 1.0f; 1 -> 0.6f; else -> 0.3f } else 1f val scale by animateFloatAsState( targetValue = targetScale, - animationSpec = if (useAnimatedLyrics) spring( - stiffness = Spring.StiffnessVeryLow, - dampingRatio = Spring.DampingRatioMediumBouncy - ) else tween(durationMillis = 200), + animationSpec = tween(durationMillis = 160, easing = FastOutSlowInEasing), label = "lineScale" ) - val verticalPadding by animateDpAsState( - targetValue = targetPadding, - animationSpec = if (useAnimatedLyrics) spring( - stiffness = Spring.StiffnessVeryLow, - dampingRatio = Spring.DampingRatioMediumBouncy - ) else tween(durationMillis = 200), - label = "linePadding" - ) val alpha by animateFloatAsState( targetValue = targetAlpha, - animationSpec = if (useAnimatedLyrics) spring( - stiffness = Spring.StiffnessLow, - dampingRatio = Spring.DampingRatioNoBouncy - ) else tween(durationMillis = 200), + animationSpec = tween(durationMillis = 140, easing = FastOutSlowInEasing), label = "lineAlpha" ) - val targetBlur = if (useAnimatedLyrics && animatedLyricsBlurEnabled && distanceFromCurrent > 0) { + val targetBlur = if (decorativeMotionEnabled && animatedLyricsBlurEnabled && distanceFromCurrent > 0) { (distanceFromCurrent * animatedLyricsBlurStrength).coerceAtMost(10f).dp } else 0.dp val blurRadius by animateDpAsState( targetValue = targetBlur, - animationSpec = if (useAnimatedLyrics) tween(durationMillis = 400) else tween(durationMillis = 200), + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), label = "lineBlur" ) + // Keep line height stable while the visual focus moves. Scaling happens in a graphics layer, + // so neither the word pulse nor the active-line transition can make the lazy list jump. + val verticalPadding = if (useAnimatedLyrics) 16.dp else 12.dp + val baseModifier = if (useAnimatedLyrics && !immersiveMode) { when (lyricsAlignment) { "center" -> modifier.padding(horizontal = 36.dp) @@ -1516,9 +1524,9 @@ fun LyricLineRow( } } } else { - val highlightedWordIndex by remember(position, sanitizedWords, line.time, lineEndTime) { + val wordVisualStates by remember(position, sanitizedWords, line.time, lineEndTime) { derivedStateOf { - resolveHighlightedWordIndex( + resolveWordVisualStates( words = requireNotNull(sanitizedWords), positionMs = position, lineStartTimeMs = line.time.toLong(), @@ -1550,8 +1558,8 @@ fun LyricLineRow( key("${line.time}_${word.time}_${word.word}_$wordIndex") { LyricWordSpan( word = word, - isHighlighted = isCurrentLine && wordIndex == highlightedWordIndex, - useAnimatedLyrics = useAnimatedLyrics, + visualState = wordVisualStates[wordIndex], + motionEnabled = decorativeMotionEnabled, style = style, highlightedColor = accentColor, unhighlightedColor = unhighlightedColor @@ -1585,40 +1593,19 @@ fun LyricLineRow( } @Composable -fun LyricWordSpan( +private fun LyricWordSpan( word: SyncedWord, - isHighlighted: Boolean, - useAnimatedLyrics: Boolean = false, + visualState: LyricWordVisualState, + motionEnabled: Boolean = false, style: TextStyle, highlightedColor: Color, unhighlightedColor: Color, modifier: Modifier = Modifier ) { - val wordAnimSpec = if (useAnimatedLyrics) spring( - stiffness = Spring.StiffnessVeryLow, - dampingRatio = Spring.DampingRatioMediumBouncy - ) else tween(durationMillis = 200) - - val color by animateColorAsState( - targetValue = if (isHighlighted) highlightedColor else unhighlightedColor, - animationSpec = if (useAnimatedLyrics) spring( - stiffness = Spring.StiffnessVeryLow, - dampingRatio = Spring.DampingRatioMediumBouncy - ) else tween(durationMillis = 200), - label = "wordColor" - ) - - val scale by animateFloatAsState( - targetValue = if (useAnimatedLyrics && isHighlighted) 1.10f else 1f, - animationSpec = wordAnimSpec, - label = "wordScale" - ) - - val alpha by animateFloatAsState( - targetValue = if (useAnimatedLyrics && !isHighlighted) 0.55f else 1f, - animationSpec = wordAnimSpec, - label = "wordAlpha" - ) + val isHighlighted = visualState.phase != LyricWordPhase.Future + val color = if (isHighlighted) highlightedColor else unhighlightedColor + val scale = resolveWordScale(visualState, motionEnabled) + val alpha = if (motionEnabled && visualState.phase == LyricWordPhase.Future) 0.55f else 1f Box( modifier = modifier, @@ -1629,6 +1616,7 @@ fun LyricWordSpan( style = style, color = Color.Transparent, fontWeight = FontWeight.Bold, + modifier = Modifier.clearAndSetSemantics { } ) Text( text = word.word, @@ -1780,6 +1768,75 @@ internal fun clusterSyncedWords(words: List): List, + positionMs: Long, + lineStartTimeMs: Long, + lineEndTimeMs: Long +): List { + if (words.isEmpty()) return emptyList() + if (positionMs < lineStartTimeMs || positionMs >= lineEndTimeMs) { + return List(words.size) { LyricWordVisualState(LyricWordPhase.Future, 0f) } + } + + val activeIndex = words.indexOfLast { it.time.toLong() <= positionMs } + if (activeIndex < 0) { + return List(words.size) { LyricWordVisualState(LyricWordPhase.Future, 0f) } + } + + val activeWordStart = words[activeIndex].time.toLong() + val nextWordStart = words.getOrNull(activeIndex + 1)?.time?.toLong() ?: lineEndTimeMs + val normalizedEnd = normalizeWordEndTime( + currentWordTimeMs = activeWordStart, + nextWordTimeMs = nextWordStart, + lineEndTimeMs = lineEndTimeMs + ) + val emphasisEnd = minOf(normalizedEnd, activeWordStart + MAX_WORD_EMPHASIS_DURATION_MS) + val emphasisDuration = (emphasisEnd - activeWordStart).coerceAtLeast(1L) + val activeProgress = ( + (positionMs - activeWordStart).toFloat() / emphasisDuration.toFloat() + ).coerceIn(0f, 1f) + + return List(words.size) { index -> + when { + index < activeIndex -> LyricWordVisualState(LyricWordPhase.Completed, 1f) + index == activeIndex -> LyricWordVisualState(LyricWordPhase.Active, activeProgress) + else -> LyricWordVisualState(LyricWordPhase.Future, 0f) + } + } +} + +/** + * A short timestamp-driven settle replaces the old unbounded spring. Graphics-layer scaling does + * not participate in measurement, and reduced-motion users always receive the stable 1x size. + */ +internal fun resolveWordScale( + state: LyricWordVisualState, + motionEnabled: Boolean +): Float { + if (!motionEnabled || state.phase != LyricWordPhase.Active) return 1f + return 1f + ACTIVE_WORD_SCALE_DELTA * (1f - state.progress.coerceIn(0f, 1f)) +} + internal fun normalizeWordEndTime( currentWordTimeMs: Long, nextWordTimeMs: Long, @@ -1790,6 +1847,39 @@ internal fun normalizeWordEndTime( return nextWordTimeMs.coerceIn(minEnd, boundedLineEnd) } +@Composable +private fun rememberInterpolatedPlaybackPosition( + sampledPositionMs: Long, + isPlaying: Boolean, + playbackSpeed: Float, + positionOverrideMs: Long? +): Long { + var interpolatedPositionMs by remember { + mutableLongStateOf(positionOverrideMs ?: sampledPositionMs) + } + + LaunchedEffect(sampledPositionMs, isPlaying, playbackSpeed, positionOverrideMs) { + positionOverrideMs?.let { + interpolatedPositionMs = it.coerceAtLeast(0L) + return@LaunchedEffect + } + + val anchorPositionMs = sampledPositionMs.coerceAtLeast(0L) + val safePlaybackSpeed = playbackSpeed.takeIf { it.isFinite() && it > 0f } ?: 1f + interpolatedPositionMs = anchorPositionMs + if (!isPlaying) return@LaunchedEffect + + val anchorFrameNanos = withFrameNanos { it } + while (true) { + val frameNanos = withFrameNanos { it } + val elapsedMs = ((frameNanos - anchorFrameNanos) / 1_000_000L).coerceAtLeast(0L) + interpolatedPositionMs = anchorPositionMs + (elapsedMs * safePlaybackSpeed).roundToLong() + } + } + + return interpolatedPositionMs +} + internal fun resolveLineEndTimeMs(line: SyncedLine, nextLineStartMs: Int): Long { val baseEnd = nextLineStartMs.toLong() val lastWordStart = line.words?.maxOfOrNull { it.time.toLong() } ?: line.time.toLong() diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerOverlaysLayer.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerOverlaysLayer.kt index 0707842b..5afa5e66 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerOverlaysLayer.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerOverlaysLayer.kt @@ -226,7 +226,7 @@ internal fun UnifiedPlayerSongInfoLayer( onNavigateToAlbum = { onNavigateToAlbum(liveSong) }, onNavigateToArtist = { onNavigateToArtist(liveSong) }, onNavigateToGenre = { onNavigateToGenre(liveSong) }, - onEditSong = { title, artist, album, albumArtist, composer, genre, lyrics, trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + onEditSong = { title, artist, album, albumArtist, composer, genre, lyrics, trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate, customMetadataChanges -> playerViewModel.editSongMetadata( liveSong, title, @@ -240,7 +240,8 @@ internal fun UnifiedPlayerSongInfoLayer( discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) onDismissSongInfo() }, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerSheetV2.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerSheetV2.kt index f09eb522..1777acfa 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerSheetV2.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerSheetV2.kt @@ -413,7 +413,9 @@ fun UnifiedPlayerSheetV2( offsetAnimatable = offsetAnimatable, screenWidthPx = screenWidthPx, onDismissPlaylistAndShowUndo = { playerViewModel.dismissPlaylistAndShowUndo() }, - onDismissStarted = { playerViewModel.setMiniPlayerDismissing(true) } + onDismissStarted = { playerViewModel.setMiniPlayerDismissing(true) }, + onPrevious = { playerViewModel.previousSong() }, + onNext = { playerViewModel.nextSong() } ) QueueSheetRuntimeEffects( diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/subcomps/FetchLyricsDialog.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/subcomps/FetchLyricsDialog.kt index 1dc62e4e..2007cd10 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/subcomps/FetchLyricsDialog.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/subcomps/FetchLyricsDialog.kt @@ -343,7 +343,7 @@ private fun ResultItemCard( result: LyricsSearchResult, onClick: () -> Unit ) { - val hasSyncedLyrics = !result.record.syncedLyrics.isNullOrEmpty() + val hasSyncedLyrics = result.record.hasSyncedContent Surface( onClick = onClick, diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LyricsfileParserTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LyricsfileParserTest.kt new file mode 100644 index 00000000..83fd6cfd --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/network/lyrics/LyricsfileParserTest.kt @@ -0,0 +1,69 @@ +package com.lostf1sh.pixelplayeross.data.network.lyrics + +import com.google.common.truth.Truth.assertThat +import com.lostf1sh.pixelplayeross.utils.LyricsUtils +import org.junit.jupiter.api.Test + +class LyricsfileParserTest { + + @Test + fun `word synced lyricsfile becomes enhanced lrc without losing text`() { + val lyricsfile = """ + version: '1.0' + metadata: + title: 'Test song' + lines: + - text: 'Hello: world #1' + start_ms: 1000 + end_ms: 2400 + words: + - text: 'Hel' + start_ms: 1000 + end_ms: 1200 + - text: 'lo ' + start_ms: 1200 + end_ms: 1500 + - text: 'world #1' + start_ms: 1500 + end_ms: 2400 + """.trimIndent() + + val enhancedLrc = LyricsfileParser.toEnhancedLrc(lyricsfile) + val parsed = LyricsUtils.parseLyrics(enhancedLrc) + + assertThat(enhancedLrc).isNotNull() + assertThat(parsed.synced).hasSize(1) + assertThat(parsed.synced!!.single().line).isEqualTo("Hello: world #1") + assertThat(parsed.synced!!.single().words!!.map { it.time }) + .containsExactly(1000, 1200, 1500).inOrder() + } + + @Test + fun `line synced lyricsfile remains usable when words are absent`() { + val lyricsfile = """ + version: '1.0' + lines: + - text: "It's only a line" + start_ms: 2500 + end_ms: 4000 + """.trimIndent() + + val enhancedLrc = LyricsfileParser.toEnhancedLrc(lyricsfile) + val parsed = LyricsUtils.parseLyrics(enhancedLrc) + + assertThat(parsed.synced!!.single().time).isEqualTo(2500) + assertThat(parsed.synced!!.single().line).isEqualTo("It's only a line") + assertThat(parsed.synced!!.single().words).isNull() + } + + @Test + fun `malformed or empty lyricsfile is ignored`() { + assertThat(LyricsfileParser.toEnhancedLrc("not: [valid")).isNull() + assertThat(LyricsfileParser.toEnhancedLrc("version: '1.0'\nlines: []")).isNull() + assertThat( + LyricsfileParser.toEnhancedLrc( + "version: '2.0'\nlines:\n - text: future\n start_ms: 0", + ), + ).isNull() + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/LyricsRepositoryImplTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/LyricsRepositoryImplTest.kt index b55a4316..d6d1e64a 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/LyricsRepositoryImplTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/LyricsRepositoryImplTest.kt @@ -266,6 +266,59 @@ class LyricsRepositoryImplTest { coVerify(exactly = 1) { lyricsDao.insert(any()) } } + @Test + fun fetchFromRemote_prefersLrclibWordSyncedLyricsfile() = runTest { + val apiService = mockk(relaxed = true) + val lyricsDao = mockk(relaxed = true) + val wordSynced = lrcResponse( + name = "Word Song", + artistName = "Word Artist", + duration = 180.0, + lyricsfile = """ + version: '1.0' + lines: + - text: 'Hello world' + start_ms: 1000 + end_ms: 3000 + words: + - text: 'Hello ' + start_ms: 1000 + end_ms: 1800 + - text: 'world' + start_ms: 1800 + end_ms: 3000 + """.trimIndent(), + ) + coEvery { lyricsDao.getLyrics(106L) } returns null + coEvery { apiService.searchLyrics(any(), any(), any(), any()) } returns arrayOf(wordSynced) + + val repository = LyricsRepositoryImpl( + context = testContext(), + lrcLibApiService = apiService, + lyricsDao = lyricsDao, + okHttpClient = mockk(relaxed = true), + userPreferencesRepository = userPreferencesRepository(), + ) + val song = testSong( + id = "106", + title = "Word Song", + artist = "Word Artist", + duration = 180_000L, + ) + + val result = repository.fetchFromRemote(song) + + assertThat(result.isSuccess).isTrue() + val (lyrics, rawLyrics) = result.getOrThrow() + assertThat(lyrics.synced!!.single().words).hasSize(2) + assertThat(lyrics.synced!!.single().words!!.map { it.time }) + .containsExactly(1000, 1800).inOrder() + assertThat(rawLyrics).contains("<00:01.00>Hello ") + coVerify(exactly = 1) { + lyricsDao.insert(match { it.isSynced && it.content == rawLyrics }) + } + } + @Test fun fetchFromRemote_doesNotTreatArtistNameInFilePathAsVariant() = runTest { val apiService = mockk(relaxed = true) @@ -339,7 +392,8 @@ class LyricsRepositoryImplTest { private fun lrcResponse( name: String, artistName: String, - duration: Double + duration: Double, + lyricsfile: String? = null, ): LrcLibResponse { return LrcLibResponse( id = name.hashCode(), @@ -348,7 +402,8 @@ class LyricsRepositoryImplTest { albumName = "Album", duration = duration, plainLyrics = null, - syncedLyrics = "[00:01.00]First line\n[00:05.00]Second line" + syncedLyrics = "[00:01.00]First line\n[00:05.00]Second line", + lyricsfile = lyricsfile, ) } } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/LyricsSheetLogicTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/LyricsSheetLogicTest.kt index 0123cf00..68606d00 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/LyricsSheetLogicTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/LyricsSheetLogicTest.kt @@ -170,6 +170,120 @@ class LyricsSheetLogicTest { assertEquals(2, idx) } + @Test + fun resolveWordVisualStates_tracksFastConsecutiveWordsWithoutLeavingPreviousWordsDim() { + val words = listOf( + SyncedWord(time = 1_000, word = "one"), + SyncedWord(time = 1_050, word = "two"), + SyncedWord(time = 1_100, word = "three") + ) + + val states = resolveWordVisualStates( + words = words, + positionMs = 1_075, + lineStartTimeMs = 1_000, + lineEndTimeMs = 1_200 + ) + + assertEquals( + listOf(LyricWordPhase.Completed, LyricWordPhase.Active, LyricWordPhase.Future), + states.map { it.phase } + ) + assertEquals(0.5f, states[1].progress, 0.001f) + } + + @Test + fun resolveWordVisualStates_usesLastDuplicateTimestampAsActiveWord() { + val words = listOf( + SyncedWord(time = 2_000, word = "first"), + SyncedWord(time = 2_000, word = "second"), + SyncedWord(time = 2_100, word = "third") + ) + + val states = resolveWordVisualStates( + words = words, + positionMs = 2_000, + lineStartTimeMs = 2_000, + lineEndTimeMs = 2_200 + ) + + assertEquals(LyricWordPhase.Completed, states[0].phase) + assertEquals(LyricWordPhase.Active, states[1].phase) + assertEquals(LyricWordPhase.Future, states[2].phase) + assertEquals(0f, states[1].progress, 0f) + } + + @Test + fun resolveWordVisualStates_zeroLengthLineStaysInactiveWithoutThrowing() { + val states = resolveWordVisualStates( + words = listOf(SyncedWord(time = 2_000, word = "instant")), + positionMs = 2_000, + lineStartTimeMs = 2_000, + lineEndTimeMs = 2_000 + ) + + assertEquals( + listOf(LyricWordVisualState(LyricWordPhase.Future, 0f)), + states + ) + } + + @Test + fun resolveWordVisualStates_accumulatesEveryCompletedWord() { + val words = listOf( + SyncedWord(time = 3_000, word = "we"), + SyncedWord(time = 3_200, word = "keep"), + SyncedWord(time = 3_400, word = "these"), + SyncedWord(time = 3_600, word = "lit") + ) + + val states = resolveWordVisualStates( + words = words, + positionMs = 3_500, + lineStartTimeMs = 3_000, + lineEndTimeMs = 3_800 + ) + + assertEquals( + listOf( + LyricWordPhase.Completed, + LyricWordPhase.Completed, + LyricWordPhase.Active, + LyricWordPhase.Future + ), + states.map { it.phase } + ) + assertEquals(listOf(1f, 1f, 0.5f, 0f), states.map { it.progress }) + } + + @Test + fun resolveWordScale_reducedMotionKeepsEveryPhaseAtStableSize() { + val states = listOf( + LyricWordVisualState(LyricWordPhase.Future, 0f), + LyricWordVisualState(LyricWordPhase.Active, 0.35f), + LyricWordVisualState(LyricWordPhase.Completed, 1f) + ) + + assertTrue(states.all { resolveWordScale(it, motionEnabled = false) == 1f }) + } + + @Test + fun resolveWordVisualStates_longWordEmphasisSettlesWithinBoundedTime() { + val states = resolveWordVisualStates( + words = listOf( + SyncedWord(time = 4_000, word = "long"), + SyncedWord(time = 6_000, word = "word") + ), + positionMs = 4_300, + lineStartTimeMs = 4_000, + lineEndTimeMs = 6_500 + ) + + assertEquals(LyricWordPhase.Active, states[0].phase) + assertEquals(1f, states[0].progress, 0f) + assertEquals(1f, resolveWordScale(states[0], motionEnabled = true), 0f) + } + @Test fun resolveSeekPositionMs_subtractsPositiveLyricsOffset() { val seekPosition = resolveSeekPositionMs( From fdc85d075fd551a5c49c6450c959e4a45ef9155f Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:05:15 +0300 Subject: [PATCH 06/18] Add app-wide player colors and swipe controls (#105) The album-art palette can now theme the whole app, and short mini-player swipes skip tracks while the existing long dismiss gesture remains available. --- .../lostf1sh/pixelplayeross/MainActivity.kt | 45 +++- .../preferences/ThemePreferencesRepository.kt | 11 + .../scoped/MiniPlayerDismissGestureHandler.kt | 192 +++++++++++++++--- .../screens/SettingsCategoryScreen.kt | 8 + .../settings/search/SettingsRegistry.kt | 16 ++ .../viewmodel/SettingsViewModel.kt | 19 +- .../viewmodel/ThemeStateHolder.kt | 16 +- .../ui/theme/AppWideNowPlayingTheme.kt | 23 +++ .../main/res/values-tr/strings_settings.xml | 2 + app/src/main/res/values/strings_settings.xml | 2 + .../ThemePreferencesRepositoryTest.kt | 33 +++ .../scoped/MiniPlayerGestureOutcomeTest.kt | 107 ++++++++++ .../ui/theme/AppWideNowPlayingThemeTest.kt | 69 +++++++ 13 files changed, 503 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/ui/theme/AppWideNowPlayingTheme.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/ThemePreferencesRepositoryTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/MiniPlayerGestureOutcomeTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/ui/theme/AppWideNowPlayingThemeTest.kt diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/MainActivity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/MainActivity.kt index 485b2bff..c85cb0fb 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/MainActivity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/MainActivity.kt @@ -134,6 +134,7 @@ import com.lostf1sh.pixelplayeross.presentation.screens.SetupScreen import com.lostf1sh.pixelplayeross.presentation.viewmodel.MainViewModel import com.lostf1sh.pixelplayeross.presentation.viewmodel.PlayerViewModel import com.lostf1sh.pixelplayeross.ui.theme.PixelPlayerTheme +import com.lostf1sh.pixelplayeross.ui.theme.resolveAppWideNowPlayingColorSchemePair import com.lostf1sh.pixelplayeross.utils.AppLocaleManager import com.lostf1sh.pixelplayeross.utils.CrashHandler import com.lostf1sh.pixelplayeross.utils.LogUtils @@ -261,6 +262,47 @@ class MainActivity : ComponentActivity() { AppThemeMode.LIGHT -> false else -> systemDarkTheme } + val globalNowPlayingThemeEnabled by themePreferencesRepository + .globalNowPlayingThemeEnabledFlow + .collectAsStateWithLifecycle(initialValue = false) + val activePlayerColorSchemePair by playerViewModel + .activePlayerColorSchemePair + .collectAsStateWithLifecycle() + val themedAlbumArtUri by playerViewModel + .currentThemedAlbumArtUri + .collectAsStateWithLifecycle() + val stablePlayerState by playerViewModel + .stablePlayerState + .collectAsStateWithLifecycle() + val currentSongId = stablePlayerState.currentSong?.id + val currentSongScheme = activePlayerColorSchemePair.takeIf { + val artworkUri = stablePlayerState.currentSong?.albumArtUriString + !artworkUri.isNullOrBlank() && artworkUri == themedAlbumArtUri + } + var lastValidNowPlayingSongId by remember { mutableStateOf(null) } + var lastValidNowPlayingScheme by remember { + mutableStateOf(null) + } + LaunchedEffect(currentSongId, currentSongScheme) { + when { + currentSongId == null -> { + lastValidNowPlayingSongId = null + lastValidNowPlayingScheme = null + } + currentSongScheme != null -> { + lastValidNowPlayingSongId = currentSongId + lastValidNowPlayingScheme = currentSongScheme + } + } + } + val appWideNowPlayingScheme = resolveAppWideNowPlayingColorSchemePair( + enabled = globalNowPlayingThemeEnabled, + currentSongId = currentSongId, + isPlaying = stablePlayerState.isPlaying, + currentSongScheme = currentSongScheme, + lastValidSongId = lastValidNowPlayingSongId, + lastValidScheme = lastValidNowPlayingScheme + ) val isSetupComplete by mainViewModel.isSetupComplete.collectAsStateWithLifecycle() var showCrashReportDialog by remember { mutableStateOf(false) } @@ -297,7 +339,8 @@ class MainActivity : ComponentActivity() { } PixelPlayerTheme( - darkTheme = useDarkTheme + darkTheme = useDarkTheme, + colorSchemePairOverride = appWideNowPlayingScheme ) { var contentVisible by remember { mutableStateOf(false) } val contentAlpha by animateFloatAsState( diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/ThemePreferencesRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/ThemePreferencesRepository.kt index f92c8a4c..5942caf3 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/ThemePreferencesRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/ThemePreferencesRepository.kt @@ -2,6 +2,7 @@ package com.lostf1sh.pixelplayeross.data.preferences import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -19,6 +20,7 @@ class ThemePreferencesRepository @Inject constructor( val ALBUM_ART_PALETTE_STYLE = stringPreferencesKey("album_art_palette_style_v1") val ALBUM_ART_COLOR_ACCURACY = intPreferencesKey("album_art_color_accuracy_v1") val APP_THEME_MODE = stringPreferencesKey("app_theme_mode") + val GLOBAL_NOW_PLAYING_THEME_ENABLED = booleanPreferencesKey("global_now_playing_theme_enabled_v1") } val appThemeModeFlow: Flow = dataStore.data.map { preferences -> @@ -29,6 +31,10 @@ class ThemePreferencesRepository @Inject constructor( preferences[Keys.PLAYER_THEME_PREFERENCE] ?: ThemePreference.ALBUM_ART } + val globalNowPlayingThemeEnabledFlow: Flow = dataStore.data.map { preferences -> + preferences[Keys.GLOBAL_NOW_PLAYING_THEME_ENABLED] ?: false + } + val albumArtPaletteStyleFlow: Flow = dataStore.data.map { preferences -> AlbumArtPaletteStyle.fromStorageKey(preferences[Keys.ALBUM_ART_PALETTE_STYLE]) } @@ -47,6 +53,11 @@ class ThemePreferencesRepository @Inject constructor( preferences[Keys.APP_THEME_MODE] = themeMode } + suspend fun setGlobalNowPlayingThemeEnabled(enabled: Boolean) = + dataStore.edit { preferences -> + preferences[Keys.GLOBAL_NOW_PLAYING_THEME_ENABLED] = enabled + } + suspend fun initializeAppThemeMode(themeMode: String) = dataStore.edit { preferences -> if (preferences[Keys.APP_THEME_MODE] == null) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/MiniPlayerDismissGestureHandler.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/MiniPlayerDismissGestureHandler.kt index 63c790d1..560cc737 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/MiniPlayerDismissGestureHandler.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/MiniPlayerDismissGestureHandler.kt @@ -14,7 +14,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.util.lerp import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineScope @@ -25,9 +28,71 @@ import kotlin.math.sign private enum class MiniDismissDragPhase { IDLE, TENSION, SNAPPING, FREE_DRAG } +internal enum class MiniPlayerGestureOutcome { + None, + Previous, + Next, + DismissLeft, + DismissRight +} + +private const val MINI_PLAYER_SKIP_DISTANCE_DP = 56f +private const val MINI_PLAYER_SKIP_MAX_DISTANCE_DP = 120f +private const val MINI_PLAYER_FLING_MIN_DISTANCE_DP = 24f +private const val MINI_PLAYER_FLING_VELOCITY_DP_PER_SECOND = 900f +private const val MINI_PLAYER_DISMISS_SCREEN_FRACTION = 0.4f + +/** + * Classifies a completed mini-player gesture without depending on pointer input state. + * + * Previous/next use physical directions on purpose: right is previous and left is next in + * both LTR and RTL, matching transport gestures in other music players. A deliberate drag + * past 40% of the screen keeps the existing queue-dismiss interaction. + */ +internal fun resolveMiniPlayerGestureOutcome( + displacementX: Float, + velocityX: Float, + screenWidthPx: Float, + density: Float, + layoutDirection: LayoutDirection +): MiniPlayerGestureOutcome { + val absoluteDistance = abs(displacementX) + if ( + screenWidthPx > 0f && + absoluteDistance > screenWidthPx * MINI_PLAYER_DISMISS_SCREEN_FRACTION + ) { + return if (displacementX < 0f) { + MiniPlayerGestureOutcome.DismissLeft + } else { + MiniPlayerGestureOutcome.DismissRight + } + } + + val safeDensity = density.coerceAtLeast(0.1f) + if (absoluteDistance > MINI_PLAYER_SKIP_MAX_DISTANCE_DP * safeDensity) { + return MiniPlayerGestureOutcome.None + } + val crossedDistanceThreshold = absoluteDistance >= MINI_PLAYER_SKIP_DISTANCE_DP * safeDensity + val crossedFlingThreshold = + absoluteDistance >= MINI_PLAYER_FLING_MIN_DISTANCE_DP * safeDensity && + abs(velocityX) >= MINI_PLAYER_FLING_VELOCITY_DP_PER_SECOND * safeDensity + if (!crossedDistanceThreshold && !crossedFlingThreshold) { + return MiniPlayerGestureOutcome.None + } + + // Intentionally enumerate both directions so RTL behavior is explicit and testable. + return when (layoutDirection) { + LayoutDirection.Ltr, + LayoutDirection.Rtl -> if (displacementX > 0f) { + MiniPlayerGestureOutcome.Previous + } else { + MiniPlayerGestureOutcome.Next + } + } +} + /** - * Keeps mini-player dismiss gesture behavior isolated from the sheet host. - * Logic is unchanged; this only centralizes gesture transitions and animation dispatch. + * Keeps mini-player transport and dismiss gestures isolated from the sheet host. */ internal class MiniPlayerDismissGestureHandler( private val scope: CoroutineScope, @@ -35,16 +100,21 @@ internal class MiniPlayerDismissGestureHandler( private val hapticFeedback: HapticFeedback, private val offsetAnimatable: Animatable, private val screenWidthPx: Float, + private val layoutDirection: LayoutDirection, private val onDismissPlaylistAndShowUndo: () -> Unit, - private val onDismissStarted: () -> Unit = {} + private val onDismissStarted: () -> Unit = {}, + private val onPrevious: () -> Unit, + private val onNext: () -> Unit ) { private var dragPhase: MiniDismissDragPhase = MiniDismissDragPhase.IDLE private var accumulatedDragX: Float = 0f private var offsetJob: Job? = null + private var hasPerformedGestureHaptic = false fun onDragStart() { dragPhase = MiniDismissDragPhase.TENSION accumulatedDragX = 0f + hasPerformedGestureHaptic = false offsetJob?.cancel() offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { offsetAnimatable.stop() @@ -72,6 +142,7 @@ internal class MiniPlayerDismissGestureHandler( MiniDismissDragPhase.SNAPPING -> { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + hasPerformedGestureHaptic = true offsetJob?.cancel() offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { offsetAnimatable.animateTo( @@ -102,34 +173,79 @@ internal class MiniPlayerDismissGestureHandler( } } - fun onDragEnd() { + fun onDragEnd(velocityX: Float) { + val completedDragX = accumulatedDragX + val outcome = resolveMiniPlayerGestureOutcome( + displacementX = completedDragX, + velocityX = velocityX, + screenWidthPx = screenWidthPx, + density = density.density, + layoutDirection = layoutDirection + ) dragPhase = MiniDismissDragPhase.IDLE offsetJob?.cancel() - val dismissThreshold = screenWidthPx * 0.4f - if (abs(accumulatedDragX) > dismissThreshold) { - onDismissStarted() - val targetDismissOffset = if (accumulatedDragX < 0) -screenWidthPx else screenWidthPx - offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { - offsetAnimatable.animateTo( - targetValue = targetDismissOffset, - animationSpec = tween( - durationMillis = 200, - easing = FastOutSlowInEasing + accumulatedDragX = 0f + + when (outcome) { + MiniPlayerGestureOutcome.DismissLeft, + MiniPlayerGestureOutcome.DismissRight -> { + performGestureHapticOnce() + onDismissStarted() + val targetDismissOffset = when (outcome) { + MiniPlayerGestureOutcome.DismissLeft -> -screenWidthPx + else -> screenWidthPx + } + offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + offsetAnimatable.animateTo( + targetValue = targetDismissOffset, + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ) ) - ) - onDismissPlaylistAndShowUndo() - offsetAnimatable.snapTo(0f) + onDismissPlaylistAndShowUndo() + offsetAnimatable.snapTo(0f) + } } - } else { - offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { - offsetAnimatable.animateTo( - targetValue = 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ) - ) + + MiniPlayerGestureOutcome.Previous -> { + performGestureHapticOnce() + onPrevious() + animateBackToRest() + } + + MiniPlayerGestureOutcome.Next -> { + performGestureHapticOnce() + onNext() + animateBackToRest() } + + MiniPlayerGestureOutcome.None -> animateBackToRest() + } + } + + fun onDragCancel() { + dragPhase = MiniDismissDragPhase.IDLE + accumulatedDragX = 0f + offsetJob?.cancel() + animateBackToRest() + } + + private fun performGestureHapticOnce() { + if (hasPerformedGestureHaptic) return + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + hasPerformedGestureHaptic = true + } + + private fun animateBackToRest() { + offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + offsetAnimatable.animateTo( + targetValue = 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) } } } @@ -142,19 +258,27 @@ internal fun rememberMiniPlayerDismissGestureHandler( offsetAnimatable: Animatable, screenWidthPx: Float, onDismissPlaylistAndShowUndo: () -> Unit, - onDismissStarted: () -> Unit + onDismissStarted: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit ): MiniPlayerDismissGestureHandler { + val layoutDirection = LocalLayoutDirection.current val onDismissPlaylistAndShowUndoState = rememberUpdatedState(onDismissPlaylistAndShowUndo) val onDismissStartedState = rememberUpdatedState(onDismissStarted) - return remember(scope, density, hapticFeedback, offsetAnimatable, screenWidthPx) { + val onPreviousState = rememberUpdatedState(onPrevious) + val onNextState = rememberUpdatedState(onNext) + return remember(scope, density, hapticFeedback, offsetAnimatable, screenWidthPx, layoutDirection) { MiniPlayerDismissGestureHandler( scope = scope, density = density, hapticFeedback = hapticFeedback, offsetAnimatable = offsetAnimatable, screenWidthPx = screenWidthPx, + layoutDirection = layoutDirection, onDismissPlaylistAndShowUndo = { onDismissPlaylistAndShowUndoState.value() }, - onDismissStarted = { onDismissStartedState.value() } + onDismissStarted = { onDismissStartedState.value() }, + onPrevious = { onPreviousState.value() }, + onNext = { onNextState.value() } ) } } @@ -165,13 +289,19 @@ internal fun Modifier.miniPlayerDismissHorizontalGesture( ): Modifier { if (!enabled) return this return this.pointerInput(enabled, handler) { + val velocityTracker = VelocityTracker() detectHorizontalDragGestures( - onDragStart = { handler.onDragStart() }, + onDragStart = { + velocityTracker.resetTracking() + handler.onDragStart() + }, onHorizontalDrag = { change, dragAmount -> + velocityTracker.addPosition(change.uptimeMillis, change.position) change.consume() handler.onHorizontalDrag(dragAmount) }, - onDragEnd = { handler.onDragEnd() } + onDragEnd = { handler.onDragEnd(velocityTracker.calculateVelocity().x) }, + onDragCancel = { handler.onDragCancel() } ) } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt index 82634630..bc820068 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt @@ -582,6 +582,14 @@ fun SettingsCategoryScreen( leadingIcon = { Icon(Icons.Outlined.LightMode, null, tint = MaterialTheme.colorScheme.secondary) }, modifier = Modifier.settingHighlight("item_appearance_app_theme", highlightKey) ) + SwitchSettingItem( + title = stringResource(R.string.setcat_global_now_playing_theme_title), + subtitle = stringResource(R.string.setcat_global_now_playing_theme_subtitle), + checked = uiState.globalNowPlayingThemeEnabled, + onCheckedChange = settingsViewModel::setGlobalNowPlayingThemeEnabled, + leadingIcon = { Icon(Icons.Outlined.PlayCircle, null, tint = MaterialTheme.colorScheme.secondary) }, + modifier = Modifier.settingHighlight("item_appearance_global_now_playing_theme", highlightKey) + ) SwitchSettingItem( title = stringResource(R.string.setcat_smooth_corners_title), subtitle = stringResource(R.string.setcat_smooth_corners_subtitle), diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt index f53cdb42..da3ab86c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt @@ -186,6 +186,22 @@ object SettingsRegistry { type = SettingType.NAVIGABLE_CARD, keywordsStatic = listOf("dark mode", "light mode", "theme", "system", "night") ), + SettingSpec( + id = "appearance_global_now_playing_theme", + itemKey = "item_appearance_global_now_playing_theme", + titleRes = R.string.setcat_global_now_playing_theme_title, + subtitleRes = R.string.setcat_global_now_playing_theme_subtitle, + category = SettingsCategory.APPEARANCE, + subscreenRoute = Screen.SettingsCategory.createRoute("appearance"), + type = SettingType.SWITCH, + keywordsStatic = listOf( + "now playing", "album art", "dynamic colors", "app theme", "palette" + ), + getValue = { it.globalNowPlayingThemeEnabled }, + onToggle = { viewModel, checked -> + viewModel.setGlobalNowPlayingThemeEnabled(checked) + } + ), SettingSpec( id = "appearance_smooth_corners", itemKey = "item_appearance_smooth_corners", diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt index 7de20a1b..3281486c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt @@ -57,6 +57,7 @@ data class SettingsUiState( val isLoadingDirectories: Boolean = false, val appLanguageTag: String = AppLanguage.SYSTEM.tag, val appThemeMode: String = AppThemeMode.FOLLOW_SYSTEM, + val globalNowPlayingThemeEnabled: Boolean = false, val playerThemePreference: String = ThemePreference.ALBUM_ART, val albumArtPaletteStyle: AlbumArtPaletteStyle = AlbumArtPaletteStyle.default, val albumArtColorAccuracy: Int = AlbumArtColorAccuracy.DEFAULT, @@ -146,7 +147,8 @@ private sealed interface SettingsUiUpdate { val libraryNavigationMode: String, val carouselStyle: String, val launchTab: String, - val showPlayerFileInfo: Boolean + val showPlayerFileInfo: Boolean, + val globalNowPlayingThemeEnabled: Boolean ) : SettingsUiUpdate data class Group2( @@ -262,7 +264,8 @@ class SettingsViewModel @Inject constructor( userPreferencesRepository.libraryNavigationModeFlow, userPreferencesRepository.carouselStyleFlow, userPreferencesRepository.launchTabFlow, - userPreferencesRepository.showPlayerFileInfoFlow + userPreferencesRepository.showPlayerFileInfoFlow, + themePreferencesRepository.globalNowPlayingThemeEnabledFlow ) { values -> SettingsUiUpdate.Group1( appRebrandDialogShown = values[0] as Boolean, @@ -277,7 +280,8 @@ class SettingsViewModel @Inject constructor( libraryNavigationMode = values[9] as String, carouselStyle = values[10] as String, launchTab = values[11] as String, - showPlayerFileInfo = values[12] as Boolean + showPlayerFileInfo = values[12] as Boolean, + globalNowPlayingThemeEnabled = values[13] as Boolean ) }.collect { update -> _uiState.update { state -> @@ -294,7 +298,8 @@ class SettingsViewModel @Inject constructor( libraryNavigationMode = update.libraryNavigationMode, carouselStyle = update.carouselStyle, launchTab = update.launchTab, - showPlayerFileInfo = update.showPlayerFileInfo + showPlayerFileInfo = update.showPlayerFileInfo, + globalNowPlayingThemeEnabled = update.globalNowPlayingThemeEnabled ) } } @@ -520,6 +525,12 @@ class SettingsViewModel @Inject constructor( } } + fun setGlobalNowPlayingThemeEnabled(enabled: Boolean) { + viewModelScope.launch { + themePreferencesRepository.setGlobalNowPlayingThemeEnabled(enabled) + } + } + fun setAlbumArtPaletteStyle(style: AlbumArtPaletteStyle) { viewModelScope.launch { themePreferencesRepository.setAlbumArtPaletteStyle(style) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolder.kt index a86e65da..3739a90b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolder.kt @@ -53,10 +53,18 @@ class ThemeStateHolder @Inject constructor( this.scope = scope scope.launch { - combine(playerThemePreference, _currentAlbumArtColorSchemePair) { playerPref, albumScheme -> - when (playerPref) { - com.lostf1sh.pixelplayeross.data.preferences.ThemePreference.ALBUM_ART -> albumScheme - else -> null + combine( + playerThemePreference, + themePreferencesRepository.globalNowPlayingThemeEnabledFlow, + _currentAlbumArtColorSchemePair + ) { playerPref, useNowPlayingColorsAppWide, albumScheme -> + if ( + playerPref == com.lostf1sh.pixelplayeross.data.preferences.ThemePreference.ALBUM_ART || + useNowPlayingColorsAppWide + ) { + albumScheme + } else { + null } }.collect { _activePlayerColorSchemePair.value = it } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/ui/theme/AppWideNowPlayingTheme.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/ui/theme/AppWideNowPlayingTheme.kt new file mode 100644 index 00000000..9a941aa4 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/ui/theme/AppWideNowPlayingTheme.kt @@ -0,0 +1,23 @@ +package com.lostf1sh.pixelplayeross.ui.theme + +import com.lostf1sh.pixelplayeross.presentation.viewmodel.ColorSchemePair + +/** + * Selects the optional application-wide now-playing palette. + * + * A missing palette falls back to the normal app theme while playing. When playback is paused, + * the last palette produced for that same song is retained so pausing does not recolor the UI. + */ +internal fun resolveAppWideNowPlayingColorSchemePair( + enabled: Boolean, + currentSongId: String?, + isPlaying: Boolean, + currentSongScheme: ColorSchemePair?, + lastValidSongId: String?, + lastValidScheme: ColorSchemePair? +): ColorSchemePair? { + if (!enabled || currentSongId == null) return null + if (currentSongScheme != null) return currentSongScheme + if (!isPlaying && currentSongId == lastValidSongId) return lastValidScheme + return null +} diff --git a/app/src/main/res/values-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml index 1f1f7471..d0cb427b 100644 --- a/app/src/main/res/values-tr/strings_settings.xml +++ b/app/src/main/res/values-tr/strings_settings.xml @@ -75,6 +75,8 @@ Uygulama Gezinmesi Açık, koyu tema arasında geçiş yapın veya sistem görünümünü takip edin. Uygulama Teması + Çalan parçanın renklerini uygulamada kullan + Tüm uygulamayı geçerli parçanın albüm kapağıyla renklendirir. Açık ve koyu mod korunur. Uygulama Çoklu sanatçı ayrıştırma ve düzenleme seçenekleri. Sanatçılar diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 92815da2..38700452 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -84,6 +84,8 @@ 简体中文 App Theme Switch between light, dark, or follow system appearance. + Use now playing colors across the app + Colors the whole app from the current song\'s album art. Light and dark mode are still respected. Light Theme Dark Theme Follow System diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/ThemePreferencesRepositoryTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/ThemePreferencesRepositoryTest.kt new file mode 100644 index 00000000..600bd754 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/ThemePreferencesRepositoryTest.kt @@ -0,0 +1,33 @@ +package com.lostf1sh.pixelplayeross.data.preferences + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.nio.file.Files + +class ThemePreferencesRepositoryTest { + + @Test + fun `app wide now playing colors are opt-in and persist`() = runTest { + val tempDir = Files.createTempDirectory("theme-preferences-repository-test") + try { + val repository = ThemePreferencesRepository( + dataStore = PreferenceDataStoreFactory.create( + scope = backgroundScope, + produceFile = { tempDir.resolve("settings.preferences_pb").toFile() } + ) + ) + + assertFalse(repository.globalNowPlayingThemeEnabledFlow.first()) + + repository.setGlobalNowPlayingThemeEnabled(true) + + assertTrue(repository.globalNowPlayingThemeEnabledFlow.first()) + } finally { + tempDir.toFile().deleteRecursively() + } + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/MiniPlayerGestureOutcomeTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/MiniPlayerGestureOutcomeTest.kt new file mode 100644 index 00000000..a6c2995d --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/MiniPlayerGestureOutcomeTest.kt @@ -0,0 +1,107 @@ +package com.lostf1sh.pixelplayeross.presentation.components.scoped + +import androidx.compose.ui.unit.LayoutDirection +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class MiniPlayerGestureOutcomeTest { + + @Test + fun `small drag below both intent thresholds does nothing`() { + val outcome = resolveMiniPlayerGestureOutcome( + displacementX = 20f, + velocityX = 300f, + screenWidthPx = 1_000f, + density = 1f, + layoutDirection = LayoutDirection.Ltr + ) + + assertThat(outcome).isEqualTo(MiniPlayerGestureOutcome.None) + } + + @Test + fun `short intentional swipe right plays previous`() { + val outcome = resolveMiniPlayerGestureOutcome( + displacementX = 64f, + velocityX = 200f, + screenWidthPx = 1_000f, + density = 1f, + layoutDirection = LayoutDirection.Ltr + ) + + assertThat(outcome).isEqualTo(MiniPlayerGestureOutcome.Previous) + } + + @Test + fun `short intentional swipe left plays next`() { + val outcome = resolveMiniPlayerGestureOutcome( + displacementX = -64f, + velocityX = -200f, + screenWidthPx = 1_000f, + density = 1f, + layoutDirection = LayoutDirection.Ltr + ) + + assertThat(outcome).isEqualTo(MiniPlayerGestureOutcome.Next) + } + + @Test + fun `fast compact fling crosses velocity intent threshold`() { + val outcome = resolveMiniPlayerGestureOutcome( + displacementX = -28f, + velocityX = -1_100f, + screenWidthPx = 1_000f, + density = 1f, + layoutDirection = LayoutDirection.Ltr + ) + + assertThat(outcome).isEqualTo(MiniPlayerGestureOutcome.Next) + } + + @Test + fun `long drag keeps playlist dismiss behavior`() { + val outcome = resolveMiniPlayerGestureOutcome( + displacementX = 450f, + velocityX = 0f, + screenWidthPx = 1_000f, + density = 1f, + layoutDirection = LayoutDirection.Ltr + ) + + assertThat(outcome).isEqualTo(MiniPlayerGestureOutcome.DismissRight) + } + + @Test + fun `cancelled long drag below dismiss threshold does not skip`() { + val outcome = resolveMiniPlayerGestureOutcome( + displacementX = 180f, + velocityX = 0f, + screenWidthPx = 1_000f, + density = 1f, + layoutDirection = LayoutDirection.Ltr + ) + + assertThat(outcome).isEqualTo(MiniPlayerGestureOutcome.None) + } + + @Test + fun `transport swipe direction stays physical in rtl`() { + val rightSwipe = resolveMiniPlayerGestureOutcome( + displacementX = 64f, + velocityX = 200f, + screenWidthPx = 1_000f, + density = 1f, + layoutDirection = LayoutDirection.Rtl + ) + val leftSwipe = resolveMiniPlayerGestureOutcome( + displacementX = -64f, + velocityX = -200f, + screenWidthPx = 1_000f, + density = 1f, + layoutDirection = LayoutDirection.Rtl + ) + + assertThat(rightSwipe).isEqualTo(MiniPlayerGestureOutcome.Previous) + assertThat(leftSwipe).isEqualTo(MiniPlayerGestureOutcome.Next) + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/ui/theme/AppWideNowPlayingThemeTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/ui/theme/AppWideNowPlayingThemeTest.kt new file mode 100644 index 00000000..afbb180a --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/ui/theme/AppWideNowPlayingThemeTest.kt @@ -0,0 +1,69 @@ +package com.lostf1sh.pixelplayeross.ui.theme + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import com.google.common.truth.Truth.assertThat +import com.lostf1sh.pixelplayeross.presentation.viewmodel.ColorSchemePair +import org.junit.Test + +class AppWideNowPlayingThemeTest { + + private val currentScheme = ColorSchemePair(lightColorScheme(), darkColorScheme()) + private val lastScheme = ColorSchemePair(lightColorScheme(), darkColorScheme()) + + @Test + fun `feature is opt-in`() { + val resolved = resolveAppWideNowPlayingColorSchemePair( + enabled = false, + currentSongId = "song-1", + isPlaying = true, + currentSongScheme = currentScheme, + lastValidSongId = "song-1", + lastValidScheme = lastScheme + ) + + assertThat(resolved).isNull() + } + + @Test + fun `ready current song palette is used`() { + val resolved = resolveAppWideNowPlayingColorSchemePair( + enabled = true, + currentSongId = "song-1", + isPlaying = true, + currentSongScheme = currentScheme, + lastValidSongId = null, + lastValidScheme = null + ) + + assertThat(resolved).isSameInstanceAs(currentScheme) + } + + @Test + fun `paused song keeps its last valid palette`() { + val resolved = resolveAppWideNowPlayingColorSchemePair( + enabled = true, + currentSongId = "song-1", + isPlaying = false, + currentSongScheme = null, + lastValidSongId = "song-1", + lastValidScheme = lastScheme + ) + + assertThat(resolved).isSameInstanceAs(lastScheme) + } + + @Test + fun `stale palette from another song is not reused`() { + val resolved = resolveAppWideNowPlayingColorSchemePair( + enabled = true, + currentSongId = "song-2", + isPlaying = false, + currentSongScheme = null, + lastValidSongId = "song-1", + lastValidScheme = lastScheme + ) + + assertThat(resolved).isNull() + } +} From dbc3b9d5d0c5f9049d1d8bbe54953caee88ab710 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:06:23 +0300 Subject: [PATCH 07/18] Apply playlist transition overrides during playback (#107) Playlist launches now carry their playlist id into media items, allowing the service to resolve the selected transition rule instead of falling back to the global default. --- .../data/service/MusicServiceWorkflowTest.kt | 112 ++++++++++++++---- .../screens/PlaylistDetailScreen.kt | 3 +- .../presentation/viewmodel/PlayerViewModel.kt | 25 ++-- .../viewmodel/PlayerViewModelTest.kt | 40 +++++++ 4 files changed, 137 insertions(+), 43 deletions(-) diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/service/MusicServiceWorkflowTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/service/MusicServiceWorkflowTest.kt index e572c919..fc62926e 100644 --- a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/service/MusicServiceWorkflowTest.kt +++ b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/service/MusicServiceWorkflowTest.kt @@ -9,6 +9,9 @@ import androidx.media3.session.MediaController import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult import androidx.media3.session.SessionToken +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.withTransaction import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -16,12 +19,13 @@ import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import com.lostf1sh.pixelplayeross.data.database.AlbumEntity import com.lostf1sh.pixelplayeross.data.database.ArtistEntity +import com.lostf1sh.pixelplayeross.data.database.MIGRATION_1_2 +import com.lostf1sh.pixelplayeross.data.database.MIGRATION_2_3 +import com.lostf1sh.pixelplayeross.data.database.MIGRATION_3_4 +import com.lostf1sh.pixelplayeross.data.database.MIGRATION_4_5 import com.lostf1sh.pixelplayeross.data.database.MusicDao +import com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase import com.lostf1sh.pixelplayeross.data.database.SongEntity -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before @@ -44,17 +48,12 @@ import java.util.concurrent.TimeUnit */ @RunWith(AndroidJUnit4::class) class MusicServiceWorkflowTest { - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface WorkflowTestEntryPoint { - fun musicDao(): MusicDao - } - private val instrumentation = InstrumentationRegistry.getInstrumentation() private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var database: PixelPlayerDatabase private lateinit var musicDao: MusicDao private lateinit var controller: MediaController + private lateinit var wavFile: File private val testSongIds = listOf(TEST_SONG_ID_1, TEST_SONG_ID_2, TEST_SONG_ID_3) @@ -66,14 +65,28 @@ class MusicServiceWorkflowTest { android.Manifest.permission.POST_NOTIFICATIONS ) } - musicDao = EntryPointAccessors - .fromApplication(context, WorkflowTestEntryPoint::class.java) - .musicDao() - - val wavFile = File(context.cacheDir, "workflow_test_tone.wav").apply { + // A test-only Hilt entry point cannot be installed into the already compiled production + // SingletonComponent. Open a test-owned connection to the same on-device database instead; + // MusicService will still exercise its real production Hilt graph and DAO. + database = Room.databaseBuilder( + context.applicationContext, + PixelPlayerDatabase::class.java, + DATABASE_NAME, + ) + .addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback()) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) + .fallbackToDestructiveMigration(dropAllTables = true) + .build() + musicDao = database.musicDao() + + wavFile = File(context.cacheDir, "workflow_test_tone.wav").apply { writeBytes(buildSilentWav(durationMs = 2_000)) } - runBlocking { seedLibrary(wavFile) } + runBlocking { + removeFixture() + seedLibrary(wavFile) + } val token = SessionToken(context, ComponentName(context, MusicService::class.java)) controller = MediaController.Builder(context, token) @@ -90,7 +103,15 @@ class MusicServiceWorkflowTest { controller.release() } } - runBlocking { musicDao.deleteSongsAndRelatedData(testSongIds) } + if (this::database.isInitialized && database.isOpen && this::musicDao.isInitialized) { + runBlocking { removeFixture() } + } + if (this::database.isInitialized) { + database.close() + } + if (this::wavFile.isInitialized) { + wavFile.delete() + } } @Test @@ -234,7 +255,49 @@ class MusicServiceWorkflowTest { mimeType = "audio/wav", ) } - musicDao.insertMusicData(songs, listOf(album), listOf(artist)) + // Parent rows must exist before songs because the production schema enforces foreign keys. + // These upserts touch only the reserved fixture ids and leave the emulator's library intact. + musicDao.insertArtists(listOf(artist)) + musicDao.insertAlbums(listOf(album)) + musicDao.insertSongs(songs) + } + + private suspend fun removeFixture() { + database.withTransaction { + musicDao.deleteCrossRefsBySongIds(testSongIds) + musicDao.deleteFavoritesBySongIds(testSongIds) + musicDao.deleteLyricsBySongIds(testSongIds) + + val stringIds = testSongIds.map(Long::toString).toTypedArray() + val stringPlaceholders = testSongIds.joinToString(separator = ",") { "?" } + val sqlite = database.openHelper.writableDatabase + sqlite.execSQL( + "DELETE FROM playlist_songs WHERE song_id IN ($stringPlaceholders)", + stringIds, + ) + sqlite.execSQL( + "DELETE FROM song_engagements WHERE song_id IN ($stringPlaceholders)", + stringIds, + ) + sqlite.execSQL( + "DELETE FROM audio_bookmarks WHERE song_id IN ($stringPlaceholders)", + stringIds, + ) + sqlite.execSQL( + "DELETE FROM offline_tracks WHERE song_id IN ($stringPlaceholders)", + stringIds, + ) + sqlite.execSQL( + "DELETE FROM transition_rules " + + "WHERE fromTrackId IN ($stringPlaceholders) " + + "OR toTrackId IN ($stringPlaceholders)", + (stringIds.asList() + stringIds.asList()).toTypedArray(), + ) + + musicDao.deleteSongsByIds(testSongIds) + sqlite.execSQL("DELETE FROM albums WHERE id = ?", arrayOf(TEST_ALBUM_ID)) + sqlite.execSQL("DELETE FROM artists WHERE id = ?", arrayOf(TEST_ARTIST_ID)) + } } private fun setSeededQueue() { @@ -290,12 +353,13 @@ class MusicServiceWorkflowTest { } private companion object { + const val DATABASE_NAME = "pixelplayer_database" const val CONNECT_TIMEOUT_SECONDS = 15L const val COMMAND_TIMEOUT_SECONDS = 10L - const val TEST_ARTIST_ID = 990_001L - const val TEST_ALBUM_ID = 990_001L - const val TEST_SONG_ID_1 = 990_101L - const val TEST_SONG_ID_2 = 990_102L - const val TEST_SONG_ID_3 = 990_103L + const val TEST_ARTIST_ID = -9_000_000_000_000_001L + const val TEST_ALBUM_ID = -9_000_000_000_000_002L + const val TEST_SONG_ID_1 = -9_000_000_000_000_101L + const val TEST_SONG_ID_2 = -9_000_000_000_000_102L + const val TEST_SONG_ID_3 = -9_000_000_000_000_103L } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt index fda8d165..3ac6a2f9 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt @@ -376,7 +376,8 @@ fun PlaylistDetailScreen( playerViewModel.playSongs( localReorderableSongs, localReorderableSongs.first(), - currentPlaylist.name + currentPlaylist.name, + currentPlaylist.id, ) if (playerStableState.isShuffleEnabled) playerViewModel.toggleShuffle() } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt index 5e0fe5f3..bfa1e656 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt @@ -2985,7 +2985,7 @@ class PlayerViewModel @Inject constructor( } _isSheetVisible.value = true - val startMediaItem = buildResolvedPlaybackMediaItem(effectiveStartSong) + val startMediaItem = buildResolvedPlaybackMediaItem(effectiveStartSong, playlistId) val playSongsAction = { dualPlayerEngine.cancelNext() @@ -3023,23 +3023,12 @@ class PlayerViewModel @Inject constructor( } } - private suspend fun buildResolvedPlaybackMediaItem(song: Song): MediaItem { - val mediaItem = MediaItemBuilder.build(song) - val originalUri = mediaItem.localConfiguration?.uri ?: return mediaItem - val scheme = originalUri.scheme - if ( - scheme != "navidrome" && - scheme != "jellyfin" - ) { - return mediaItem - } - - val resolvedUri = dualPlayerEngine.resolveCloudUri(originalUri) - return if (resolvedUri == originalUri) { - mediaItem - } else { - mediaItem.buildUpon().setUri(resolvedUri).build() - } + private suspend fun buildResolvedPlaybackMediaItem( + song: Song, + playlistId: String? = null, + ): MediaItem { + val mediaItem = buildPlaybackMediaItem(song, playlistId) + return dualPlayerEngine.resolveMediaItem(mediaItem) } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModelTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModelTest.kt index 549a9cb4..c933dca0 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModelTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModelTest.kt @@ -1,6 +1,8 @@ package com.lostf1sh.pixelplayeross.presentation.viewmodel import android.content.Context +import android.net.Uri +import android.os.Bundle import app.cash.turbine.test import com.lostf1sh.pixelplayeross.data.database.AlbumArtThemeDao import com.google.common.util.concurrent.ListenableFuture @@ -379,6 +381,44 @@ class PlayerViewModelTest { } } + @Test + fun `playlist playback keeps playlist id on the initial media item`() = runTest { + mockkStatic(Uri::class) + mockkConstructor(Bundle::class) + val playbackUri = mockk(relaxed = true) + every { Uri.parse(any()) } returns playbackUri + every { playbackUri.scheme } returns "content" + every { anyConstructed().putString(any(), any()) } just Runs + val player = mockk(relaxed = true) + every { mockDualPlayerEngine.masterPlayer } returns player + val song = Song( + id = "playlist-song", + title = "Playlist Song", + artist = "Artist", + artistId = 1L, + album = "Album", + albumId = 1L, + path = "/storage/emulated/0/Music/playlist-song.mp3", + contentUriString = "content://media/external/audio/media/42", + albumArtUriString = null, + duration = 180_000L, + mimeType = "audio/mpeg", + bitrate = null, + sampleRate = null, + ) + + playerViewModel.playSongs( + songsToPlay = listOf(song), + startSong = song, + queueName = "Road Trip", + playlistId = "playlist-42", + ) + advanceUntilIdle() + + verify { anyConstructed().putString("playlistId", "playlist-42") } + verify { player.setMediaItem(any(), 0L) } + } + @Test fun `toggleFavorite resolves external current song to MediaStore favorite id`() = runTest { val externalSong = Song( From a0562a8109f62dd7c2df954d99fa6ff74a6c1be2 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:07:08 +0300 Subject: [PATCH 08/18] Declare data sync foreground work correctly (#108) Download and sync workers now start with the required dataSync service type and permission on modern Android releases instead of crashing the app. --- app/build.gradle.kts | 4 +- .../data/worker/SyncWorkerTest.kt | 64 ++++++++++++++++--- app/src/main/AndroidManifest.xml | 6 ++ gradle/libs.versions.toml | 2 + 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5062b8e..694a58e8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -300,12 +300,12 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.truth) - androidTestImplementation(libs.mockk) + androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.worktesting) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) - androidTestImplementation(libs.androidx.benchmark.macro.junit4) androidTestImplementation(libs.androidx.uiautomator) debugImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt index d7e4e192..593b2995 100644 --- a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt +++ b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt @@ -1,13 +1,16 @@ package com.lostf1sh.pixelplayeross.data.worker +import android.Manifest import android.content.Context import android.database.MatrixCursor import android.net.Uri +import android.os.Build import android.provider.MediaStore import androidx.concurrent.futures.ResolvableFuture import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule import androidx.work.ListenableWorker import androidx.work.WorkerFactory import androidx.work.WorkerParameters @@ -15,12 +18,16 @@ import androidx.work.testing.TestListenableWorkerBuilder import com.google.common.truth.Truth.assertThat import com.lostf1sh.pixelplayeross.data.database.MusicDao import com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase +import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.io.IOException @@ -28,16 +35,22 @@ import java.io.IOException @RunWith(AndroidJUnit4::class) class SyncWorkerTest { + @get:Rule + val mediaReadPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_AUDIO + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + ) + private lateinit var context: Context private lateinit var database: PixelPlayerDatabase private lateinit var musicDao: MusicDao private lateinit var mockContentResolver: android.content.ContentResolver - class TestSyncWorkerFactory( - private val dao: MusicDao, - private val resolver: android.content.ContentResolver? = null - ) : WorkerFactory() { + class TestSyncWorkerFactory(private val dao: MusicDao) : WorkerFactory() { override fun createWorker( appContext: Context, workerClassName: String, @@ -48,7 +61,7 @@ class SyncWorkerTest { appContext = appContext, workerParams = workerParameters, musicDao = dao, - userPreferencesRepository = mockk(relaxed = true), + userPreferencesRepository = createTestPreferencesRepository(), lyricsRepository = mockk(relaxed = true), cloudSyncCoordinator = mockk(relaxed = true) ) @@ -135,7 +148,7 @@ class SyncWorkerTest { .build() val result = worker.doWork() - assertThat(result).isEqualTo(ListenableWorker.Result.success()) + assertSuccessfulSongCount(result, expectedCount = 2) val songsInDb = musicDao.getSongs(emptyList(), false).first() assertThat(songsInDb).hasSize(2) @@ -147,12 +160,16 @@ class SyncWorkerTest { val artistsInDb = musicDao.getArtists(emptyList(), false).first() assertThat(artistsInDb).hasSize(2) - assertThat(artistsInDb.find { it.id == 101L }?.name).isEqualTo("Test Artist 1") + assertThat(artistsInDb.map { it.name }) + .containsExactly("Test Artist 1", "Test Artist 2") + Unit } @Test fun testSyncWorker_success_whenMediaStoreIsEmpty() = runBlocking { - every { mockContentResolver.query(any(), any(), any(), any(), any()) } returns MatrixCursor(arrayOf()) + every { mockContentResolver.query(any(), any(), any(), any(), any()) } answers { + MatrixCursor(secondArg?>() ?: emptyArray()) + } val testContext = object : ContextWrapper(context) { override fun getContentResolver(): android.content.ContentResolver { @@ -165,11 +182,40 @@ class SyncWorkerTest { .build() val result = worker.doWork() - assertThat(result).isEqualTo(ListenableWorker.Result.success()) + assertSuccessfulSongCount(result, expectedCount = 0) assertThat(musicDao.getSongCount().first()).isEqualTo(0) assertThat(musicDao.getAlbumCount().first()).isEqualTo(0) assertThat(musicDao.getArtistCount().first()).isEqualTo(0) } + + private fun assertSuccessfulSongCount( + result: ListenableWorker.Result, + expectedCount: Int, + ) { + assertThat(result).isInstanceOf(ListenableWorker.Result.Success::class.java) + val output = (result as ListenableWorker.Result.Success).outputData + assertThat(output.getInt(SyncWorker.OUTPUT_TOTAL_SONGS, -1)).isEqualTo(expectedCount) + } +} + +private fun createTestPreferencesRepository(): UserPreferencesRepository { + val repository = mockk(relaxed = true) + every { repository.artistDelimitersFlow } returns + flowOf(UserPreferencesRepository.DEFAULT_ARTIST_DELIMITERS) + every { repository.artistWordDelimitersFlow } returns + flowOf(UserPreferencesRepository.DEFAULT_ARTIST_WORD_DELIMITERS) + every { repository.extractArtistsFromTitleFlow } returns flowOf(false) + every { repository.groupByAlbumArtistFlow } returns flowOf(false) + every { repository.artistSettingsRescanRequiredFlow } returns flowOf(false) + every { repository.allowedDirectoriesFlow } returns flowOf(emptySet()) + every { repository.blockedDirectoriesFlow } returns flowOf(emptySet()) + every { repository.minTracksPerAlbumFlow } returns flowOf(1) + every { repository.autoScanLrcFilesFlow } returns flowOf(false) + coEvery { repository.getDirectoryRulesVersion() } returns 0 + coEvery { repository.getLastAppliedDirectoryRulesVersion() } returns 0 + coEvery { repository.getLastSyncTimestamp() } returns 0L + coEvery { repository.getMinSongDuration() } returns 10_000 + return repository } open class ContextWrapper(base: Context) : android.content.ContextWrapper(base) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 856f9b32..ee3c72f2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ android:maxSdkVersion="32" /> + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad3565c6..bb8ef824 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -170,10 +170,12 @@ androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchm androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestCore" } junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junitJupiter" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junitJupiter" } junit-vintage-engine = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junitJupiter" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } org-json = { group = "org.json", name = "json", version.ref = "orgJson" } From 16d010e87987a82af086ab19684090b085c5462d Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:08:32 +0300 Subject: [PATCH 09/18] Select whole albums and artists for batch actions (#109) Library selection now resolves visible album and artist songs once, removes duplicates from multi-artist tracks, and exposes the existing batch actions for the resolved set. --- .../presentation/screens/LibraryScreen.kt | 425 +++++++++++++----- .../selection/LibrarySelectionUtils.kt | 69 +++ .../viewmodel/MultiSelectionStateHolder.kt | 9 + .../presentation/viewmodel/PlayerViewModel.kt | 72 ++- .../selection/LibrarySelectionUtilsTest.kt | 69 +++ .../MultiSelectionStateHolderTest.kt | 24 + 6 files changed, 533 insertions(+), 135 deletions(-) create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/presentation/selection/LibrarySelectionUtils.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/presentation/selection/LibrarySelectionUtilsTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MultiSelectionStateHolderTest.kt diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt index 8892871f..5c486100 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt @@ -165,7 +165,6 @@ import com.lostf1sh.pixelplayeross.presentation.components.SongInfoBottomSheet import com.lostf1sh.pixelplayeross.presentation.components.subcomps.LibraryActionRow import com.lostf1sh.pixelplayeross.presentation.navigation.Screen import com.lostf1sh.pixelplayeross.presentation.components.MultiSelectionBottomSheet -import com.lostf1sh.pixelplayeross.presentation.components.AlbumMultiSelectionOptionSheet import com.lostf1sh.pixelplayeross.presentation.components.PlaylistMultiSelectionBottomSheet import com.lostf1sh.pixelplayeross.presentation.components.DescribePlaylistDialog import com.lostf1sh.pixelplayeross.presentation.components.PlaylistCreationTypeDialog @@ -186,6 +185,9 @@ import com.lostf1sh.pixelplayeross.data.worker.SyncProgress import com.lostf1sh.pixelplayeross.presentation.screens.search.components.GenreTypography import com.lostf1sh.pixelplayeross.presentation.components.SyncProgressBar import com.lostf1sh.pixelplayeross.presentation.viewmodel.LibraryViewModel +import com.lostf1sh.pixelplayeross.presentation.selection.appendDistinctSelection +import com.lostf1sh.pixelplayeross.presentation.selection.selectionIndex +import com.lostf1sh.pixelplayeross.presentation.selection.toggleOrderedSelection import com.lostf1sh.pixelplayeross.utils.formatSongCount import androidx.paging.compose.collectAsLazyPagingItems import android.content.Intent @@ -225,6 +227,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.CancellationException import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import androidx.compose.material3.FilledIconButton import androidx.compose.material3.ModalBottomSheet @@ -242,6 +245,7 @@ import com.lostf1sh.pixelplayeross.presentation.components.subcomps.PlayingEqIco import com.lostf1sh.pixelplayeross.ui.theme.RoundedSans import java.util.Locale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLocale import android.widget.Toast import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.ui.focus.focusModifier @@ -260,7 +264,6 @@ import com.lostf1sh.pixelplayeross.presentation.components.rememberModalSheetSta val ListExtraBottomGap = 30.dp val PlayerSheetCollapsedCornerRadius = 32.dp -private const val MAX_ALBUM_MULTI_SELECTION = 6 private const val ENABLE_FOLDERS_SOURCE_TOGGLE = true private const val ENABLE_FOLDERS_STORAGE_FILTER = false private const val FOLDER_NAVIGATION_ROOT_KEY = "__folder_root__" @@ -389,7 +392,11 @@ fun LibraryScreen( var selectedAlbums by remember { mutableStateOf>(persistentListOf()) } val selectedAlbumIds = remember(selectedAlbums) { selectedAlbums.map { it.id }.toSet() } val isAlbumSelectionMode = selectedAlbums.isNotEmpty() - var showAlbumMultiSelectionSheet by remember { mutableStateOf(false) } + var selectedArtists by remember { mutableStateOf>(persistentListOf()) } + val selectedArtistIds = remember(selectedArtists) { selectedArtists.map { it.id }.toSet() } + val isArtistSelectionMode = selectedArtists.isNotEmpty() + val latestSelectedAlbums by rememberUpdatedState(selectedAlbums) + val latestSelectedArtists by rememberUpdatedState(selectedArtists) var showBatchEditSheet by remember { mutableStateOf(false) } var songsShowLocateButton by remember { mutableStateOf(false) } @@ -411,16 +418,12 @@ fun LibraryScreen( { song -> multiSelectionState.toggleSelection(song) } } - val toggleAlbumSelection: (Album) -> Unit = remember(selectedAlbums, playerViewModel, context) { + val toggleAlbumSelection: (Album) -> Unit = remember(selectedAlbums, multiSelectionState) { { album -> - val existingIndex = selectedAlbums.indexOfFirst { it.id == album.id } - if (existingIndex >= 0) { - selectedAlbums = selectedAlbums.toMutableList().also { it.removeAt(existingIndex) }.toImmutableList() - } else if (selectedAlbums.size >= MAX_ALBUM_MULTI_SELECTION) { - playerViewModel.sendToast(context.getString(R.string.presentation_batch_d_max_albums_selection, MAX_ALBUM_MULTI_SELECTION)) - } else { - selectedAlbums = (selectedAlbums + album).toImmutableList() - } + // A changed category selection invalidates any songs resolved for an earlier snapshot. + multiSelectionState.clearSelection() + showMultiSelectionSheet = false + selectedAlbums = toggleOrderedSelection(selectedAlbums, album, Album::id).toImmutableList() } } @@ -436,9 +439,97 @@ fun LibraryScreen( } val getAlbumSelectionIndex: (Long) -> Int? = remember(selectedAlbums) { - { albumId -> - val index = selectedAlbums.indexOfFirst { it.id == albumId } - if (index >= 0) index + 1 else null + { albumId -> selectionIndex(selectedAlbums, albumId, Album::id) } + } + + val toggleArtistSelection: (Artist) -> Unit = remember(selectedArtists, multiSelectionState) { + { artist -> + // A changed category selection invalidates any songs resolved for an earlier snapshot. + multiSelectionState.clearSelection() + showMultiSelectionSheet = false + selectedArtists = toggleOrderedSelection(selectedArtists, artist, Artist::id).toImmutableList() + } + } + + val onArtistLongPress: (Artist) -> Unit = remember(toggleArtistSelection, haptic) { + { artist -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + toggleArtistSelection(artist) + } + } + + val onArtistSelectionToggle: (Artist) -> Unit = remember(toggleArtistSelection) { + { artist -> toggleArtistSelection(artist) } + } + + val getArtistSelectionIndex: (Long) -> Int? = remember(selectedArtists) { + { artistId -> selectionIndex(selectedArtists, artistId, Artist::id) } + } + + val presentResolvedCategorySongs: (List) -> Unit = remember( + multiSelectionState, + playerViewModel, + context + ) { + { songs -> + if (songs.isEmpty()) { + playerViewModel.sendToast(context.getString(R.string.no_valid_songs)) + } else { + multiSelectionState.replaceSelection(songs) + showMultiSelectionSheet = true + } + } + } + + val openSelectedAlbumActions: () -> Unit = remember( + selectedAlbums, + playerViewModel, + scope, + presentResolvedCategorySongs + ) { + { + val selectionSnapshot = selectedAlbums.toList() + val snapshotIds = selectionSnapshot.map(Album::id) + scope.launch { + runCatching { + playerViewModel.resolveAlbumSongsForBatchActions(selectionSnapshot) + }.onSuccess { songs -> + if (latestSelectedAlbums.map(Album::id) == snapshotIds) { + presentResolvedCategorySongs(songs) + } + } + .onFailure { error -> + if (error is CancellationException) throw error + Timber.tag("LibrarySelection").e(error, "Unable to resolve selected albums") + playerViewModel.sendToast(context.getString(R.string.no_valid_songs)) + } + } + } + } + + val openSelectedArtistActions: () -> Unit = remember( + selectedArtists, + playerViewModel, + scope, + presentResolvedCategorySongs + ) { + { + val selectionSnapshot = selectedArtists.toList() + val snapshotIds = selectionSnapshot.map(Artist::id) + scope.launch { + runCatching { + playerViewModel.resolveArtistSongsForBatchActions(selectionSnapshot) + }.onSuccess { songs -> + if (latestSelectedArtists.map(Artist::id) == snapshotIds) { + presentResolvedCategorySongs(songs) + } + } + .onFailure { error -> + if (error is CancellationException) throw error + Timber.tag("LibrarySelection").e(error, "Unable to resolve selected artists") + playerViewModel.sendToast(context.getString(R.string.no_valid_songs)) + } + } } } @@ -541,7 +632,7 @@ fun LibraryScreen( LibraryTabId.SONGS, LibraryTabId.LIKED, LibraryTabId.FOLDERS -> isSelectionMode - LibraryTabId.ARTISTS -> false + LibraryTabId.ARTISTS -> isArtistSelectionMode } } } @@ -566,7 +657,14 @@ fun LibraryScreen( LibraryTabId.ALBUMS -> { selectedAlbums = persistentListOf() - showAlbumMultiSelectionSheet = false + multiSelectionState.clearSelection() + showMultiSelectionSheet = false + } + + LibraryTabId.ARTISTS -> { + selectedArtists = persistentListOf() + multiSelectionState.clearSelection() + showMultiSelectionSheet = false } LibraryTabId.SONGS, @@ -576,7 +674,6 @@ fun LibraryScreen( showMultiSelectionSheet = false } - LibraryTabId.ARTISTS -> Unit } } @@ -612,9 +709,9 @@ fun LibraryScreen( multiSelectionState.clearSelection() playlistMultiSelectionState.clearSelection() selectedAlbums = persistentListOf() + selectedArtists = persistentListOf() showMultiSelectionSheet = false showPlaylistMultiSelectionSheet = false - showAlbumMultiSelectionSheet = false } val fabState by remember { derivedStateOf { currentTabIndex } } @@ -776,7 +873,7 @@ fun LibraryScreen( } ) { Text( - text = stringResource(tabId.titleRes).uppercase(Locale.getDefault()), + text = stringResource(tabId.titleRes).uppercase(LocalLocale.current.platformLocale), style = MaterialTheme.typography.labelLarge, fontWeight = if (currentTabIndex == index) FontWeight.Bold else FontWeight.Medium ) @@ -920,7 +1017,7 @@ fun LibraryScreen( } AnimatedContent( - targetState = isSelectionMode || isPlaylistSelectionMode || isAlbumSelectionMode, + targetState = hasSelectionInCurrentTab, label = "ActionRowModeSwitch", transitionSpec = { (slideInHorizontally { -it } + fadeIn()) togetherWith @@ -948,25 +1045,39 @@ fun LibraryScreen( SelectionActionRow( selectedCount = selectedAlbums.size, onSelectAll = { - val remaining = MAX_ALBUM_MULTI_SELECTION - selectedAlbums.size - if (remaining <= 0) { - playerViewModel.sendToast( - context.getString( - R.string.presentation_batch_d_max_albums_selection, - MAX_ALBUM_MULTI_SELECTION - ) - ) - } else { - val albumsToAppend = playerViewModel.albumsFlow.value - .filterNot { selectedAlbumIds.contains(it.id) } - .take(remaining) - if (albumsToAppend.isNotEmpty()) { - selectedAlbums = (selectedAlbums + albumsToAppend).toImmutableList() - } - } + multiSelectionState.clearSelection() + showMultiSelectionSheet = false + selectedAlbums = appendDistinctSelection( + current = selectedAlbums, + candidates = playerViewModel.albumsFlow.value, + keyOf = Album::id + ).toImmutableList() + }, + onDeselect = { + selectedAlbums = persistentListOf() + multiSelectionState.clearSelection() + showMultiSelectionSheet = false + }, + onOptionsClick = openSelectedAlbumActions + ) + } else if (currentTabId == LibraryTabId.ARTISTS && isArtistSelectionMode) { + SelectionActionRow( + selectedCount = selectedArtists.size, + onSelectAll = { + multiSelectionState.clearSelection() + showMultiSelectionSheet = false + selectedArtists = appendDistinctSelection( + current = selectedArtists, + candidates = playerViewModel.artistsFlow.value, + keyOf = Artist::id + ).toImmutableList() + }, + onDeselect = { + selectedArtists = persistentListOf() + multiSelectionState.clearSelection() + showMultiSelectionSheet = false }, - onDeselect = { selectedAlbums = persistentListOf() }, - onOptionsClick = { showAlbumMultiSelectionSheet = true } + onOptionsClick = openSelectedArtistActions ) } else { SelectionActionRow( @@ -1272,6 +1383,11 @@ fun LibraryScreen( }, isRefreshing = isRefreshing, onRefresh = onRefresh, + isSelectionMode = isArtistSelectionMode, + selectedArtistIds = selectedArtistIds, + onArtistLongPress = onArtistLongPress, + onArtistSelectionToggle = onArtistSelectionToggle, + getSelectionIndex = getArtistSelectionIndex, storageFilter = playerUiState.currentStorageFilter ) } @@ -1373,10 +1489,13 @@ fun LibraryScreen( } } - val selectionCount = when { - currentTabId == LibraryTabId.PLAYLISTS && isPlaylistSelectionMode -> selectedPlaylists.size - currentTabId == LibraryTabId.ALBUMS && isAlbumSelectionMode -> selectedAlbums.size - else -> selectedSongs.size + val selectionCount = when (currentTabId) { + LibraryTabId.PLAYLISTS -> selectedPlaylists.size.takeIf { isPlaylistSelectionMode } ?: 0 + LibraryTabId.ALBUMS -> selectedAlbums.size.takeIf { isAlbumSelectionMode } ?: 0 + LibraryTabId.ARTISTS -> selectedArtists.size.takeIf { isArtistSelectionMode } ?: 0 + LibraryTabId.SONGS, + LibraryTabId.LIKED, + LibraryTabId.FOLDERS -> selectedSongs.size } SelectionCountPill( selectedCount = selectionCount, @@ -1574,27 +1693,35 @@ fun LibraryScreen( ) } - if (showMultiSelectionSheet && selectedSongs.isNotEmpty()) { - val activity = context as? android.app.Activity + val clearResolvedCategorySelection: () -> Unit = { + selectedAlbums = persistentListOf() + selectedArtists = persistentListOf() + } + if (showMultiSelectionSheet && selectedSongs.isNotEmpty()) { MultiSelectionBottomSheet( selectedSongs = selectedSongs, favoriteSongIds = favoriteIds, onDismiss = { showMultiSelectionSheet = false }, onPlayAll = { playerViewModel.playSelectedSongs(selectedSongs) + clearResolvedCategorySelection() showMultiSelectionSheet = false }, onAddToQueue = { playerViewModel.addSelectedToQueue(selectedSongs) + clearResolvedCategorySelection() showMultiSelectionSheet = false }, onPlayNext = { playerViewModel.addSelectedAsNext(selectedSongs) + clearResolvedCategorySelection() showMultiSelectionSheet = false }, onAddToPlaylist = { playlistSheetSongs = selectedSongs + clearResolvedCategorySelection() + multiSelectionState.clearSelection() showMultiSelectionSheet = false showPlaylistBottomSheet = true }, @@ -1604,27 +1731,29 @@ fun LibraryScreen( } else { playerViewModel.unlikeSelectedSongs(selectedSongs) } + clearResolvedCategorySelection() showMultiSelectionSheet = false }, onShareAll = { playerViewModel.shareSelectedAsZip(selectedSongs) + clearResolvedCategorySelection() showMultiSelectionSheet = false }, onDownloadAll = { - val cloudSongCount = selectedSongs.count(CloudOfflineRepository::isCloudSong) + val cloudSongCount = CloudOfflineRepository.downloadCandidates(selectedSongs).size cloudDownloadsViewModel.downloadSelected(selectedSongs) multiSelectionState.clearSelection() + clearResolvedCategorySelection() playerViewModel.sendToast( context.getString(R.string.cloud_download_selected_started, cloudSongCount) ) showMultiSelectionSheet = false }, - onDeleteAll = { _, onComplete -> - activity?.let { - playerViewModel.deleteSelectedFromDevice(it, selectedSongs) { - showMultiSelectionSheet = false - onComplete(true) - } + onDeleteAll = { deleteActivity, onComplete -> + playerViewModel.deleteSelectedFromDevice(deleteActivity, selectedSongs) { + clearResolvedCategorySelection() + showMultiSelectionSheet = false + onComplete(true) } }, onBatchEdit = { @@ -1634,29 +1763,6 @@ fun LibraryScreen( ) } - if (showAlbumMultiSelectionSheet && selectedAlbums.isNotEmpty()) { - AlbumMultiSelectionOptionSheet( - selectedAlbums = selectedAlbums, - maxSelection = MAX_ALBUM_MULTI_SELECTION, - onDismiss = { showAlbumMultiSelectionSheet = false }, - onPlay = { - playerViewModel.playSelectedAlbums(selectedAlbums) - selectedAlbums = persistentListOf() - showAlbumMultiSelectionSheet = false - }, - onPlayNext = { - playerViewModel.addSelectedAlbumsAsNext(selectedAlbums) - selectedAlbums = persistentListOf() - showAlbumMultiSelectionSheet = false - }, - onAddToQueue = { - playerViewModel.addSelectedAlbumsToQueue(selectedAlbums) - selectedAlbums = persistentListOf() - showAlbumMultiSelectionSheet = false - } - ) - } - if (showPlaylistMultiSelectionSheet && selectedPlaylists.isNotEmpty()) { val activity = context as? android.app.Activity @@ -1811,6 +1917,7 @@ fun LibraryScreen( replayGainAlbumGainDb = replayGainAlbumGainDb, coverArtUpdate = coverArtUpdate ) + clearResolvedCategorySelection() } ) } @@ -3053,68 +3160,140 @@ fun AlbumGridItemRedesigned( @androidx.annotation.OptIn(UnstableApi::class) @Composable -fun ArtistListItem(artist: Artist, onClick: () -> Unit, isLoading: Boolean = false) { +fun ArtistListItem( + artist: Artist, + onClick: () -> Unit, + isLoading: Boolean = false, + isSelectionMode: Boolean = false, + isSelected: Boolean = false, + selectionIndex: Int? = null, + onLongPress: () -> Unit = {}, + onSelectionToggle: () -> Unit = {} +) { + val cardShape = RoundedCornerShape(18.dp) + val selectionScale by animateFloatAsState( + targetValue = if (isSelected) 0.99f else 1f, + animationSpec = tween(durationMillis = 200), + label = "artistSelectionScale" + ) + val selectionBorderWidth by animateDpAsState( + targetValue = if (isSelected) 2.dp else 0.dp, + animationSpec = tween(durationMillis = 200), + label = "artistSelectionBorder" + ) + Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .scale(selectionScale) + .then( + if (isSelected) { + Modifier.border( + width = selectionBorderWidth, + color = MaterialTheme.colorScheme.primary, + shape = cardShape + ) + } else { + Modifier + } + ) + .clip(cardShape) + .combinedClickable( + enabled = !isLoading, + onClick = { + if (isSelectionMode) onSelectionToggle() else onClick() + }, + onLongClick = onLongPress + ), + shape = cardShape, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) ) { - Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - if (isLoading) { - ShimmerBox( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 12.dp, + top = 12.dp, + end = if (isSelected) 54.dp else 12.dp, + bottom = 12.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + if (isLoading) { ShimmerBox( modifier = Modifier - .fillMaxWidth(0.6f) - .height(20.dp) - .clip(RoundedCornerShape(4.dp)) + .size(48.dp) + .clip(CircleShape) ) - Spacer(modifier = Modifier.height(4.dp)) - ShimmerBox( + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(20.dp) + .clip(RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(4.dp)) + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.3f) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + ) + } + } else { + Box( modifier = Modifier - .fillMaxWidth(0.3f) - .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - ) + .size(48.dp) + .clip(ShapeCache.expressiveAvatar) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + if (!artist.effectiveImageUrl.isNullOrEmpty()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(artist.effectiveImageUrl) + .crossfade(true) + .build(), + contentDescription = artist.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon( + painter = painterResource(R.drawable.rounded_artist_24), + contentDescription = stringResource(R.string.presentation_batch_d_cd_artist), + modifier = Modifier.padding(8.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(artist.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(formatSongCount(artist.songCount), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } - } else { + } + + if (isSelectionMode && isSelected) { Box( modifier = Modifier - .size(48.dp) - .clip(ShapeCache.expressiveAvatar) - .background(MaterialTheme.colorScheme.primaryContainer), + .align(Alignment.CenterEnd) + .padding(end = 14.dp) + .size(28.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), contentAlignment = Alignment.Center ) { - if (!artist.effectiveImageUrl.isNullOrEmpty()) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(artist.effectiveImageUrl) - .crossfade(true) - .build(), - contentDescription = artist.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - } else { - Icon( - painter = painterResource(R.drawable.rounded_artist_24), - contentDescription = stringResource(R.string.presentation_batch_d_cd_artist), - modifier = Modifier.padding(8.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text(artist.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text(formatSongCount(artist.songCount), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = selectionIndex?.toString() ?: "✓", + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) } } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/selection/LibrarySelectionUtils.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/selection/LibrarySelectionUtils.kt new file mode 100644 index 00000000..0e10c61e --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/selection/LibrarySelectionUtils.kt @@ -0,0 +1,69 @@ +package com.lostf1sh.pixelplayeross.presentation.selection + +import com.lostf1sh.pixelplayeross.data.model.StorageFilter + +/** Mirrors the filter used by the visible library when local media is globally hidden. */ +internal fun effectiveLibraryStorageFilter( + selected: StorageFilter, + hideLocalMedia: Boolean, +): StorageFilter = if (hideLocalMedia) StorageFilter.ONLINE else selected + +/** Toggles one item while preserving the order in which categories were selected. */ +internal fun toggleOrderedSelection( + current: List, + item: T, + keyOf: (T) -> K +): List { + val itemKey = keyOf(item) + return if (current.any { keyOf(it) == itemKey }) { + current.filterNot { keyOf(it) == itemKey } + } else { + current + item + } +} + +/** Adds all previously-unselected items in candidate order. */ +internal fun appendDistinctSelection( + current: List, + candidates: Iterable, + keyOf: (T) -> K +): List { + val result = current.toMutableList() + val selectedKeys = current.mapTo(linkedSetOf(), keyOf) + candidates.forEach { candidate -> + if (selectedKeys.add(keyOf(candidate))) { + result += candidate + } + } + return result +} + +/** Returns the one-based selection position used by the category selection badges. */ +internal fun selectionIndex( + current: List, + key: K, + keyOf: (T) -> K +): Int? { + val index = current.indexOfFirst { keyOf(it) == key } + return index.takeIf { it >= 0 }?.plus(1) +} + +/** + * Flattens resolved album/artist song groups without performing a destructive action twice when + * the same song belongs to more than one selected artist. + */ +internal fun flattenDistinctGroups( + groups: Iterable>, + keyOf: (T) -> K +): List { + val result = mutableListOf() + val seenKeys = hashSetOf() + groups.forEach { group -> + group.forEach { item -> + if (seenKeys.add(keyOf(item))) { + result += item + } + } + } + return result +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MultiSelectionStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MultiSelectionStateHolder.kt index 2a0f2f25..1e31831e 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MultiSelectionStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MultiSelectionStateHolder.kt @@ -91,6 +91,15 @@ class MultiSelectionStateHolder @Inject constructor() { updateState(currentList, newIds) } + /** + * Replaces any previous selection with a resolved album/artist song set. + * Duplicate IDs are removed while the first occurrence and its order are retained. + */ + fun replaceSelection(songs: List) { + val uniqueSongs = songs.distinctBy { it.id } + updateState(uniqueSongs, uniqueSongs.mapTo(linkedSetOf()) { it.id }) + } + /** * Clears all selected songs, exiting selection mode. */ diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt index bfa1e656..9fa0f2dd 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt @@ -75,6 +75,8 @@ import com.lostf1sh.pixelplayeross.utils.StorageType import com.lostf1sh.pixelplayeross.utils.StorageUtils import com.lostf1sh.pixelplayeross.utils.traceSection import com.lostf1sh.pixelplayeross.utils.ZipShareHelper +import com.lostf1sh.pixelplayeross.presentation.selection.flattenDistinctGroups +import com.lostf1sh.pixelplayeross.presentation.selection.effectiveLibraryStorageFilter import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList @@ -579,13 +581,7 @@ class PlayerViewModel @Inject constructor( try { val sortOption = playerUiState.value.currentSongSortOption - val baseFilter = playerUiState.value.currentStorageFilter - val hideLocal = playerUiState.value.hideLocalMedia - val storageFilter = if (hideLocal) { - com.lostf1sh.pixelplayeross.data.model.StorageFilter.ONLINE - } else { - baseFilter - } + val storageFilter = currentEffectiveLibraryStorageFilter() val sortedIds = musicRepository.getSongIdsSorted(sortOption, storageFilter) @@ -620,7 +616,7 @@ class PlayerViewModel @Inject constructor( failureMessage = "Failed to build full library queue for songId=%s" ) { val sortOption = playerUiState.value.currentSongSortOption - val storageFilter = playerUiState.value.currentStorageFilter + val storageFilter = currentEffectiveLibraryStorageFilter() musicRepository.getSongIdsSorted(sortOption, storageFilter) } } @@ -637,18 +633,29 @@ class PlayerViewModel @Inject constructor( failureMessage = "Failed to build favorites queue for songId=%s" ) { val sortOption = playerUiState.value.currentFavoriteSortOption - val storageFilter = playerUiState.value.currentStorageFilter + val storageFilter = currentEffectiveLibraryStorageFilter() musicRepository.getFavoriteSongIdsSorted(sortOption, storageFilter) } } suspend fun getSongsForCurrentLibrarySelection(): List { - val sortOption = playerUiState.value.currentSongSortOption - val storageFilter = playerUiState.value.currentStorageFilter - val sortedIds = musicRepository.getSongIdsSorted(sortOption, storageFilter) + val state = playerUiState.value + val sortedIds = musicRepository.getSongIdsSorted( + state.currentSongSortOption, + currentEffectiveLibraryStorageFilter(state), + ) return resolvePlaybackQueueFromSortedIds(sortedIds) } + private fun currentEffectiveLibraryStorageFilter( + state: PlayerUiState = playerUiState.value, + ): com.lostf1sh.pixelplayeross.data.model.StorageFilter { + return effectiveLibraryStorageFilter( + selected = state.currentStorageFilter, + hideLocalMedia = state.hideLocalMedia, + ) + } + private fun launchLatestFullQueuePlayback( song: Song, queueName: String, @@ -3440,6 +3447,47 @@ class PlayerViewModel @Inject constructor( ) } + /** Resolves whole albums to the currently visible songs consumed by the batch-action sheet. */ + suspend fun resolveAlbumSongsForBatchActions(albums: List): List { + if (albums.isEmpty()) return emptyList() + val visibleSongIds = currentLibrarySelectionSongIds() + return withContext(Dispatchers.IO) { + flattenDistinctGroups( + groups = albums.map { album -> + sortSongsForAlbumSelection(musicRepository.getSongsForAlbum(album.id).first()) + .filter { it.id in visibleSongIds } + }, + keyOf = Song::id + ) + } + } + + /** + * Resolves whole artists to songs and removes overlaps from multi-artist tracks so delete, + * edit, queue and playlist actions are each applied once per file. + */ + suspend fun resolveArtistSongsForBatchActions(artists: List): List { + if (artists.isEmpty()) return emptyList() + val visibleSongIds = currentLibrarySelectionSongIds() + return withContext(Dispatchers.IO) { + flattenDistinctGroups( + groups = artists.map { artist -> + musicRepository.getSongsForArtist(artist.id).first() + .filter { it.id in visibleSongIds } + }, + keyOf = Song::id + ) + } + } + + private suspend fun currentLibrarySelectionSongIds(): Set { + val state = playerUiState.value + return musicRepository.getSongIdsSorted( + sortOption = state.currentSongSortOption, + storageFilter = currentEffectiveLibraryStorageFilter(state), + ).mapTo(hashSetOf()) { it.toString() } + } + private fun sortSongsForAlbumSelection(songs: List): List { return songs.sortedWith( compareBy { it.discNumber ?: 1 } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/selection/LibrarySelectionUtilsTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/selection/LibrarySelectionUtilsTest.kt new file mode 100644 index 00000000..6f10cc9d --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/selection/LibrarySelectionUtilsTest.kt @@ -0,0 +1,69 @@ +package com.lostf1sh.pixelplayeross.presentation.selection + +import com.lostf1sh.pixelplayeross.data.model.StorageFilter +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class LibrarySelectionUtilsTest { + + private data class Item(val id: Long, val label: String) + + @Test + fun `hidden local media forces online filter for category batch actions`() { + assertEquals( + StorageFilter.ONLINE, + effectiveLibraryStorageFilter(StorageFilter.ALL, hideLocalMedia = true), + ) + assertEquals( + StorageFilter.OFFLINE, + effectiveLibraryStorageFilter(StorageFilter.OFFLINE, hideLocalMedia = false), + ) + } + + @Test + fun `toggleOrderedSelection preserves selection order and removes by identity`() { + val first = Item(1, "First") + val second = Item(2, "Second") + + val selected = toggleOrderedSelection(emptyList(), first, Item::id) + .let { toggleOrderedSelection(it, second, Item::id) } + + assertEquals(listOf(first, second), selected) + assertEquals(listOf(second), toggleOrderedSelection(selected, first.copy(label = "Updated"), Item::id)) + } + + @Test + fun `appendDistinctSelection appends only new identities`() { + val first = Item(1, "First") + val second = Item(2, "Second") + val third = Item(3, "Third") + + val selected = appendDistinctSelection( + current = listOf(first, second), + candidates = listOf(second.copy(label = "Duplicate"), third), + keyOf = Item::id + ) + + assertEquals(listOf(first, second, third), selected) + assertEquals(2, selectionIndex(selected, 2L, Item::id)) + assertNull(selectionIndex(selected, 99L, Item::id)) + } + + @Test + fun `flattenDistinctGroups keeps category and song order while removing overlaps`() { + val first = Item(1, "First") + val shared = Item(2, "Shared") + val last = Item(3, "Last") + + val resolved = flattenDistinctGroups( + groups = listOf( + listOf(first, shared), + listOf(shared.copy(label = "Shared duplicate"), last) + ), + keyOf = Item::id + ) + + assertEquals(listOf(first, shared, last), resolved) + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MultiSelectionStateHolderTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MultiSelectionStateHolderTest.kt new file mode 100644 index 00000000..e4305440 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MultiSelectionStateHolderTest.kt @@ -0,0 +1,24 @@ +package com.lostf1sh.pixelplayeross.presentation.viewmodel + +import com.lostf1sh.pixelplayeross.data.model.Song +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MultiSelectionStateHolderTest { + + @Test + fun `replaceSelection uses the resolved category order and removes duplicate songs`() { + val holder = MultiSelectionStateHolder() + val first = Song.emptySong().copy(id = "1", title = "First") + val shared = Song.emptySong().copy(id = "2", title = "Shared") + + holder.toggleSelection(Song.emptySong().copy(id = "old", title = "Old selection")) + holder.replaceSelection(listOf(first, shared, shared.copy(title = "Duplicate"))) + + assertEquals(listOf(first, shared), holder.selectedSongs.value) + assertEquals(setOf("1", "2"), holder.selectedSongIds.value) + assertEquals(2, holder.selectedCount.value) + assertTrue(holder.isSelectionMode.value) + } +} From 92d202cfcdca7e5559c1470af2e98d1bd61ea15f Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:09:13 +0300 Subject: [PATCH 10/18] Synchronize M3U playlists in both directions (#110) Users can choose an external folder, reconcile file and app changes safely, run sync on demand, and keep synchronization active across app foreground sessions. --- app/build.gradle.kts | 2 + .../6.json | 2301 +++++++++++++++++ .../data/database/LocalPlaylistDaoTest.kt | 50 + .../data/database/PlaylistMigrationTest.kt | 66 + .../data/service/MusicServiceWorkflowTest.kt | 9 +- .../pixelplayeross/PixelPlayerApplication.kt | 12 + .../data/database/Migrations.kt | 46 + .../data/database/PixelPlayerDatabase.kt | 2 +- .../data/database/PlaylistSongEntity.kt | 2 +- .../data/playlist/M3uManager.kt | 155 +- .../data/playlist/M3uSyncPlanner.kt | 43 + .../data/playlist/M3uSyncRepository.kt | 770 ++++++ .../data/playlist/M3uSyncWorker.kt | 141 + .../data/preferences/M3uSyncPreferences.kt | 112 + .../lostf1sh/pixelplayeross/di/AppModule.kt | 9 +- .../screens/SettingsCategoryScreen.kt | 87 + .../settings/search/SettingsRegistry.kt | 20 + .../viewmodel/M3uSyncViewModel.kt | 50 + .../main/res/values-tr/strings_settings.xml | 8 + app/src/main/res/values/strings_settings.xml | 10 +- .../data/playlist/M3uManagerTest.kt | 120 + .../data/playlist/M3uSyncPlannerTest.kt | 61 + .../data/playlist/M3uSyncSafetyTest.kt | 67 + .../preferences/M3uSyncPreferencesTest.kt | 61 + gradle/libs.versions.toml | 3 + 25 files changed, 4163 insertions(+), 44 deletions(-) create mode 100644 app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/6.json create mode 100644 app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/LocalPlaylistDaoTest.kt create mode 100644 app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/PlaylistMigrationTest.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncPlanner.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncRepository.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncWorker.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/M3uSyncPreferences.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/M3uSyncViewModel.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uManagerTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncPlannerTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncSafetyTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/M3uSyncPreferencesTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 694a58e8..2d94a706 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -257,6 +257,7 @@ dependencies { implementation(libs.logging.interceptor) implementation(libs.gson) implementation(libs.kotlinx.serialization.json) + implementation(libs.snakeyaml) implementation(libs.kotlinx.collections.immutable) implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) @@ -301,6 +302,7 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.truth) androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.worktesting) diff --git a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/6.json b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/6.json new file mode 100644 index 00000000..e54e25bc --- /dev/null +++ b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/6.json @@ -0,0 +1,2301 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "2421f7b88e49bbf7b646386861b5e504", + "entities": [ + { + "tableName": "album_art_themes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumArtUriString` TEXT NOT NULL, `paletteStyle` TEXT NOT NULL, `light_primary` TEXT NOT NULL, `light_onPrimary` TEXT NOT NULL, `light_primaryContainer` TEXT NOT NULL, `light_onPrimaryContainer` TEXT NOT NULL, `light_secondary` TEXT NOT NULL, `light_onSecondary` TEXT NOT NULL, `light_secondaryContainer` TEXT NOT NULL, `light_onSecondaryContainer` TEXT NOT NULL, `light_tertiary` TEXT NOT NULL, `light_onTertiary` TEXT NOT NULL, `light_tertiaryContainer` TEXT NOT NULL, `light_onTertiaryContainer` TEXT NOT NULL, `light_background` TEXT NOT NULL, `light_onBackground` TEXT NOT NULL, `light_surface` TEXT NOT NULL, `light_onSurface` TEXT NOT NULL, `light_surfaceVariant` TEXT NOT NULL, `light_onSurfaceVariant` TEXT NOT NULL, `light_error` TEXT NOT NULL, `light_onError` TEXT NOT NULL, `light_outline` TEXT NOT NULL, `light_errorContainer` TEXT NOT NULL, `light_onErrorContainer` TEXT NOT NULL, `light_inversePrimary` TEXT NOT NULL, `light_inverseSurface` TEXT NOT NULL, `light_inverseOnSurface` TEXT NOT NULL, `light_surfaceTint` TEXT NOT NULL, `light_outlineVariant` TEXT NOT NULL, `light_scrim` TEXT NOT NULL, `light_surfaceBright` TEXT NOT NULL, `light_surfaceDim` TEXT NOT NULL, `light_surfaceContainer` TEXT NOT NULL, `light_surfaceContainerHigh` TEXT NOT NULL, `light_surfaceContainerHighest` TEXT NOT NULL, `light_surfaceContainerLow` TEXT NOT NULL, `light_surfaceContainerLowest` TEXT NOT NULL, `light_primaryFixed` TEXT NOT NULL, `light_primaryFixedDim` TEXT NOT NULL, `light_onPrimaryFixed` TEXT NOT NULL, `light_onPrimaryFixedVariant` TEXT NOT NULL, `light_secondaryFixed` TEXT NOT NULL, `light_secondaryFixedDim` TEXT NOT NULL, `light_onSecondaryFixed` TEXT NOT NULL, `light_onSecondaryFixedVariant` TEXT NOT NULL, `light_tertiaryFixed` TEXT NOT NULL, `light_tertiaryFixedDim` TEXT NOT NULL, `light_onTertiaryFixed` TEXT NOT NULL, `light_onTertiaryFixedVariant` TEXT NOT NULL, `dark_primary` TEXT NOT NULL, `dark_onPrimary` TEXT NOT NULL, `dark_primaryContainer` TEXT NOT NULL, `dark_onPrimaryContainer` TEXT NOT NULL, `dark_secondary` TEXT NOT NULL, `dark_onSecondary` TEXT NOT NULL, `dark_secondaryContainer` TEXT NOT NULL, `dark_onSecondaryContainer` TEXT NOT NULL, `dark_tertiary` TEXT NOT NULL, `dark_onTertiary` TEXT NOT NULL, `dark_tertiaryContainer` TEXT NOT NULL, `dark_onTertiaryContainer` TEXT NOT NULL, `dark_background` TEXT NOT NULL, `dark_onBackground` TEXT NOT NULL, `dark_surface` TEXT NOT NULL, `dark_onSurface` TEXT NOT NULL, `dark_surfaceVariant` TEXT NOT NULL, `dark_onSurfaceVariant` TEXT NOT NULL, `dark_error` TEXT NOT NULL, `dark_onError` TEXT NOT NULL, `dark_outline` TEXT NOT NULL, `dark_errorContainer` TEXT NOT NULL, `dark_onErrorContainer` TEXT NOT NULL, `dark_inversePrimary` TEXT NOT NULL, `dark_inverseSurface` TEXT NOT NULL, `dark_inverseOnSurface` TEXT NOT NULL, `dark_surfaceTint` TEXT NOT NULL, `dark_outlineVariant` TEXT NOT NULL, `dark_scrim` TEXT NOT NULL, `dark_surfaceBright` TEXT NOT NULL, `dark_surfaceDim` TEXT NOT NULL, `dark_surfaceContainer` TEXT NOT NULL, `dark_surfaceContainerHigh` TEXT NOT NULL, `dark_surfaceContainerHighest` TEXT NOT NULL, `dark_surfaceContainerLow` TEXT NOT NULL, `dark_surfaceContainerLowest` TEXT NOT NULL, `dark_primaryFixed` TEXT NOT NULL, `dark_primaryFixedDim` TEXT NOT NULL, `dark_onPrimaryFixed` TEXT NOT NULL, `dark_onPrimaryFixedVariant` TEXT NOT NULL, `dark_secondaryFixed` TEXT NOT NULL, `dark_secondaryFixedDim` TEXT NOT NULL, `dark_onSecondaryFixed` TEXT NOT NULL, `dark_onSecondaryFixedVariant` TEXT NOT NULL, `dark_tertiaryFixed` TEXT NOT NULL, `dark_tertiaryFixedDim` TEXT NOT NULL, `dark_onTertiaryFixed` TEXT NOT NULL, `dark_onTertiaryFixedVariant` TEXT NOT NULL, PRIMARY KEY(`albumArtUriString`))", + "fields": [ + { + "fieldPath": "albumArtUriString", + "columnName": "albumArtUriString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paletteStyle", + "columnName": "paletteStyle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.primary", + "columnName": "light_primary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onPrimary", + "columnName": "light_onPrimary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.primaryContainer", + "columnName": "light_primaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onPrimaryContainer", + "columnName": "light_onPrimaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.secondary", + "columnName": "light_secondary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSecondary", + "columnName": "light_onSecondary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.secondaryContainer", + "columnName": "light_secondaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSecondaryContainer", + "columnName": "light_onSecondaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.tertiary", + "columnName": "light_tertiary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onTertiary", + "columnName": "light_onTertiary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.tertiaryContainer", + "columnName": "light_tertiaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onTertiaryContainer", + "columnName": "light_onTertiaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.background", + "columnName": "light_background", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onBackground", + "columnName": "light_onBackground", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surface", + "columnName": "light_surface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSurface", + "columnName": "light_onSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceVariant", + "columnName": "light_surfaceVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSurfaceVariant", + "columnName": "light_onSurfaceVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.error", + "columnName": "light_error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onError", + "columnName": "light_onError", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.outline", + "columnName": "light_outline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.errorContainer", + "columnName": "light_errorContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onErrorContainer", + "columnName": "light_onErrorContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.inversePrimary", + "columnName": "light_inversePrimary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.inverseSurface", + "columnName": "light_inverseSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.inverseOnSurface", + "columnName": "light_inverseOnSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceTint", + "columnName": "light_surfaceTint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.outlineVariant", + "columnName": "light_outlineVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.scrim", + "columnName": "light_scrim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceBright", + "columnName": "light_surfaceBright", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceDim", + "columnName": "light_surfaceDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceContainer", + "columnName": "light_surfaceContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceContainerHigh", + "columnName": "light_surfaceContainerHigh", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceContainerHighest", + "columnName": "light_surfaceContainerHighest", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceContainerLow", + "columnName": "light_surfaceContainerLow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceContainerLowest", + "columnName": "light_surfaceContainerLowest", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.primaryFixed", + "columnName": "light_primaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.primaryFixedDim", + "columnName": "light_primaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onPrimaryFixed", + "columnName": "light_onPrimaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onPrimaryFixedVariant", + "columnName": "light_onPrimaryFixedVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.secondaryFixed", + "columnName": "light_secondaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.secondaryFixedDim", + "columnName": "light_secondaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSecondaryFixed", + "columnName": "light_onSecondaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSecondaryFixedVariant", + "columnName": "light_onSecondaryFixedVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.tertiaryFixed", + "columnName": "light_tertiaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.tertiaryFixedDim", + "columnName": "light_tertiaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onTertiaryFixed", + "columnName": "light_onTertiaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onTertiaryFixedVariant", + "columnName": "light_onTertiaryFixedVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.primary", + "columnName": "dark_primary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onPrimary", + "columnName": "dark_onPrimary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.primaryContainer", + "columnName": "dark_primaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onPrimaryContainer", + "columnName": "dark_onPrimaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.secondary", + "columnName": "dark_secondary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSecondary", + "columnName": "dark_onSecondary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.secondaryContainer", + "columnName": "dark_secondaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSecondaryContainer", + "columnName": "dark_onSecondaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.tertiary", + "columnName": "dark_tertiary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onTertiary", + "columnName": "dark_onTertiary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.tertiaryContainer", + "columnName": "dark_tertiaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onTertiaryContainer", + "columnName": "dark_onTertiaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.background", + "columnName": "dark_background", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onBackground", + "columnName": "dark_onBackground", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surface", + "columnName": "dark_surface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSurface", + "columnName": "dark_onSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceVariant", + "columnName": "dark_surfaceVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSurfaceVariant", + "columnName": "dark_onSurfaceVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.error", + "columnName": "dark_error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onError", + "columnName": "dark_onError", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.outline", + "columnName": "dark_outline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.errorContainer", + "columnName": "dark_errorContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onErrorContainer", + "columnName": "dark_onErrorContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.inversePrimary", + "columnName": "dark_inversePrimary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.inverseSurface", + "columnName": "dark_inverseSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.inverseOnSurface", + "columnName": "dark_inverseOnSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceTint", + "columnName": "dark_surfaceTint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.outlineVariant", + "columnName": "dark_outlineVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.scrim", + "columnName": "dark_scrim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceBright", + "columnName": "dark_surfaceBright", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceDim", + "columnName": "dark_surfaceDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceContainer", + "columnName": "dark_surfaceContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceContainerHigh", + "columnName": "dark_surfaceContainerHigh", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceContainerHighest", + "columnName": "dark_surfaceContainerHighest", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceContainerLow", + "columnName": "dark_surfaceContainerLow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceContainerLowest", + "columnName": "dark_surfaceContainerLowest", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.primaryFixed", + "columnName": "dark_primaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.primaryFixedDim", + "columnName": "dark_primaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onPrimaryFixed", + "columnName": "dark_onPrimaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onPrimaryFixedVariant", + "columnName": "dark_onPrimaryFixedVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.secondaryFixed", + "columnName": "dark_secondaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.secondaryFixedDim", + "columnName": "dark_secondaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSecondaryFixed", + "columnName": "dark_onSecondaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSecondaryFixedVariant", + "columnName": "dark_onSecondaryFixedVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.tertiaryFixed", + "columnName": "dark_tertiaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.tertiaryFixedDim", + "columnName": "dark_tertiaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onTertiaryFixed", + "columnName": "dark_onTertiaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onTertiaryFixedVariant", + "columnName": "dark_onTertiaryFixedVariant", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumArtUriString" + ] + }, + "indices": [ + { + "name": "index_album_art_themes_albumArtUriString_paletteStyle", + "unique": false, + "columnNames": [ + "albumArtUriString", + "paletteStyle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_art_themes_albumArtUriString_paletteStyle` ON `${TABLE_NAME}` (`albumArtUriString`, `paletteStyle`)" + } + ] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_artist` TEXT, `album_artist_id` INTEGER NOT NULL DEFAULT 0, `album_name` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `content_uri_string` TEXT NOT NULL, `album_art_uri_string` TEXT, `duration` INTEGER NOT NULL, `genre` TEXT, `file_path` TEXT NOT NULL, `parent_directory_path` TEXT NOT NULL, `is_favorite` INTEGER NOT NULL DEFAULT 0, `lyrics` TEXT DEFAULT null, `track_number` INTEGER NOT NULL DEFAULT 0, `disc_number` INTEGER DEFAULT null, `year` INTEGER NOT NULL DEFAULT 0, `date_added` INTEGER NOT NULL DEFAULT 0, `mime_type` TEXT, `bitrate` INTEGER, `sample_rate` INTEGER, `artists_json` TEXT, `source_type` INTEGER NOT NULL DEFAULT 0, `media_store_date_added` INTEGER NOT NULL DEFAULT 0, `media_store_date_modified` INTEGER NOT NULL DEFAULT 0, `title_user_edited` INTEGER NOT NULL DEFAULT 0, `artist_user_edited` INTEGER NOT NULL DEFAULT 0, `album_user_edited` INTEGER NOT NULL DEFAULT 0, `genre_user_edited` INTEGER NOT NULL DEFAULT 0, `mb_recording_id` TEXT, `mb_release_id` TEXT, `mb_artist_id` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`album_id`) REFERENCES `albums`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtistId", + "columnName": "album_artist_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "albumName", + "columnName": "album_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentUriString", + "columnName": "content_uri_string", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtUriString", + "columnName": "album_art_uri_string", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "filePath", + "columnName": "file_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDirectoryPath", + "columnName": "parent_directory_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "defaultValue": "null" + }, + { + "fieldPath": "trackNumber", + "columnName": "track_number", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "defaultValue": "null" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT" + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER" + }, + { + "fieldPath": "sampleRate", + "columnName": "sample_rate", + "affinity": "INTEGER" + }, + { + "fieldPath": "artistsJson", + "columnName": "artists_json", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mediaStoreDateAdded", + "columnName": "media_store_date_added", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mediaStoreDateModified", + "columnName": "media_store_date_modified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "titleUserEdited", + "columnName": "title_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "artistUserEdited", + "columnName": "artist_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "albumUserEdited", + "columnName": "album_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "genreUserEdited", + "columnName": "genre_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mbRecordingId", + "columnName": "mb_recording_id", + "affinity": "TEXT" + }, + { + "fieldPath": "mbReleaseId", + "columnName": "mb_release_id", + "affinity": "TEXT" + }, + { + "fieldPath": "mbArtistId", + "columnName": "mb_artist_id", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_songs_title", + "unique": false, + "columnNames": [ + "title" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_title` ON `${TABLE_NAME}` (`title`)" + }, + { + "name": "index_songs_album_id", + "unique": false, + "columnNames": [ + "album_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_album_id` ON `${TABLE_NAME}` (`album_id`)" + }, + { + "name": "index_songs_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_songs_artist_name", + "unique": false, + "columnNames": [ + "artist_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_name` ON `${TABLE_NAME}` (`artist_name`)" + }, + { + "name": "index_songs_genre", + "unique": false, + "columnNames": [ + "genre" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_genre` ON `${TABLE_NAME}` (`genre`)" + }, + { + "name": "index_songs_parent_directory_path", + "unique": false, + "columnNames": [ + "parent_directory_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path` ON `${TABLE_NAME}` (`parent_directory_path`)" + }, + { + "name": "index_songs_file_path", + "unique": false, + "columnNames": [ + "file_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_file_path` ON `${TABLE_NAME}` (`file_path`)" + }, + { + "name": "index_songs_content_uri_string", + "unique": false, + "columnNames": [ + "content_uri_string" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_content_uri_string` ON `${TABLE_NAME}` (`content_uri_string`)" + }, + { + "name": "index_songs_date_added", + "unique": false, + "columnNames": [ + "date_added" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_date_added` ON `${TABLE_NAME}` (`date_added`)" + }, + { + "name": "index_songs_duration", + "unique": false, + "columnNames": [ + "duration" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_duration` ON `${TABLE_NAME}` (`duration`)" + }, + { + "name": "index_songs_source_type", + "unique": false, + "columnNames": [ + "source_type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_source_type` ON `${TABLE_NAME}` (`source_type`)" + }, + { + "name": "index_songs_album_artist_id", + "unique": false, + "columnNames": [ + "album_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_album_artist_id` ON `${TABLE_NAME}` (`album_artist_id`)" + }, + { + "name": "index_songs_parent_directory_path_source_type_album_id", + "unique": false, + "columnNames": [ + "parent_directory_path", + "source_type", + "album_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_album_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `album_id`)" + }, + { + "name": "index_songs_parent_directory_path_source_type_id", + "unique": false, + "columnNames": [ + "parent_directory_path", + "source_type", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "albums", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "album_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artists", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "songs_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, tokenize=unicode61)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_art_uri_string` TEXT, `song_count` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `year` INTEGER NOT NULL, `album_artist` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumArtUriString", + "columnName": "album_art_uri_string", + "affinity": "TEXT" + }, + { + "fieldPath": "songCount", + "columnName": "song_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_albums_title", + "unique": false, + "columnNames": [ + "title" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_title` ON `${TABLE_NAME}` (`title`)" + }, + { + "name": "index_albums_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_albums_artist_name", + "unique": false, + "columnNames": [ + "artist_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_name` ON `${TABLE_NAME}` (`artist_name`)" + }, + { + "name": "index_albums_album_artist", + "unique": false, + "columnNames": [ + "album_artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_album_artist` ON `${TABLE_NAME}` (`album_artist`)" + } + ] + }, + { + "tableName": "artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `track_count` INTEGER NOT NULL, `image_url` TEXT, `custom_image_uri` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackCount", + "columnName": "track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "customImageUri", + "columnName": "custom_image_uri", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "transition_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `fromTrackId` TEXT, `toTrackId` TEXT, `mode` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `curveIn` TEXT NOT NULL, `curveOut` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromTrackId", + "columnName": "fromTrackId", + "affinity": "TEXT" + }, + { + "fieldPath": "toTrackId", + "columnName": "toTrackId", + "affinity": "TEXT" + }, + { + "fieldPath": "settings.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "settings.durationMs", + "columnName": "durationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "settings.curveIn", + "columnName": "curveIn", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "settings.curveOut", + "columnName": "curveOut", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_transition_rules_playlistId_fromTrackId_toTrackId", + "unique": true, + "columnNames": [ + "playlistId", + "fromTrackId", + "toTrackId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_transition_rules_playlistId_fromTrackId_toTrackId` ON `${TABLE_NAME}` (`playlistId`, `fromTrackId`, `toTrackId`)" + } + ] + }, + { + "tableName": "song_artist_cross_ref", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `is_primary` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`song_id`, `artist_id`), FOREIGN KEY(`song_id`) REFERENCES `songs`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "is_primary", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "artist_id" + ] + }, + "indices": [ + { + "name": "index_song_artist_cross_ref_song_id", + "unique": false, + "columnNames": [ + "song_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_song_id` ON `${TABLE_NAME}` (`song_id`)" + }, + { + "name": "index_song_artist_cross_ref_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_song_artist_cross_ref_is_primary", + "unique": false, + "columnNames": [ + "is_primary" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_is_primary` ON `${TABLE_NAME}` (`is_primary`)" + } + ], + "foreignKeys": [ + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "song_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_engagements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `play_count` INTEGER NOT NULL, `total_play_duration_ms` INTEGER NOT NULL, `last_played_timestamp` INTEGER NOT NULL, PRIMARY KEY(`song_id`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayDurationMs", + "columnName": "total_play_duration_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedTimestamp", + "columnName": "last_played_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id" + ] + }, + "indices": [ + { + "name": "index_song_engagements_play_count", + "unique": false, + "columnNames": [ + "play_count" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_engagements_play_count` ON `${TABLE_NAME}` (`play_count`)" + } + ] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`songId`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [ + { + "name": "index_favorites_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + } + ] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `content` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `source` TEXT, PRIMARY KEY(`songId`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + } + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `is_queue_generated` INTEGER NOT NULL, `cover_image_uri` TEXT, `cover_color_argb` INTEGER, `cover_icon_name` TEXT, `cover_shape_type` TEXT, `cover_shape_detail_1` REAL, `cover_shape_detail_2` REAL, `cover_shape_detail_3` REAL, `cover_shape_detail_4` REAL, `source` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isQueueGenerated", + "columnName": "is_queue_generated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverImageUri", + "columnName": "cover_image_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "coverColorArgb", + "columnName": "cover_color_argb", + "affinity": "INTEGER" + }, + { + "fieldPath": "coverIconName", + "columnName": "cover_icon_name", + "affinity": "TEXT" + }, + { + "fieldPath": "coverShapeType", + "columnName": "cover_shape_type", + "affinity": "TEXT" + }, + { + "fieldPath": "coverShapeDetail1", + "columnName": "cover_shape_detail_1", + "affinity": "REAL" + }, + { + "fieldPath": "coverShapeDetail2", + "columnName": "cover_shape_detail_2", + "affinity": "REAL" + }, + { + "fieldPath": "coverShapeDetail3", + "columnName": "cover_shape_detail_3", + "affinity": "REAL" + }, + { + "fieldPath": "coverShapeDetail4", + "columnName": "cover_shape_detail_4", + "affinity": "REAL" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_last_modified", + "unique": false, + "columnNames": [ + "last_modified" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_last_modified` ON `${TABLE_NAME}` (`last_modified`)" + } + ] + }, + { + "tableName": "playlist_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `song_id` TEXT NOT NULL, `sort_order` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `sort_order`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "sort_order" + ] + }, + "indices": [ + { + "name": "index_playlist_songs_playlist_id_sort_order", + "unique": false, + "columnNames": [ + "playlist_id", + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_playlist_id_sort_order` ON `${TABLE_NAME}` (`playlist_id`, `sort_order`)" + }, + { + "name": "index_playlist_songs_song_id", + "unique": false, + "columnNames": [ + "song_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_song_id` ON `${TABLE_NAME}` (`song_id`)" + } + ] + }, + { + "tableName": "navidrome_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `navidrome_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album_artist` TEXT, `album` TEXT NOT NULL, `album_id` TEXT, `cover_art_id` TEXT, `duration` INTEGER NOT NULL, `track_number` INTEGER NOT NULL, `disc_number` INTEGER NOT NULL, `year` INTEGER NOT NULL, `genre` TEXT, `bitRate` INTEGER, `mime_type` TEXT, `suffix` TEXT, `path` TEXT NOT NULL, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "navidromeId", + "columnName": "navidrome_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackNumber", + "columnName": "track_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "bitRate", + "columnName": "bitRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT" + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_navidrome_songs_navidrome_id", + "unique": false, + "columnNames": [ + "navidrome_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_navidrome_id` ON `${TABLE_NAME}` (`navidrome_id`)" + }, + { + "name": "index_navidrome_songs_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + }, + { + "name": "index_navidrome_songs_playlist_id_date_added", + "unique": false, + "columnNames": [ + "playlist_id", + "date_added" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)" + } + ] + }, + { + "tableName": "navidrome_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `owner` TEXT, `cover_art_id` TEXT, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `public` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT" + }, + { + "fieldPath": "songCount", + "columnName": "song_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "public", + "columnName": "public", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncTime", + "columnName": "last_sync_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "jellyfin_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `jellyfin_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album_artist` TEXT, `album` TEXT NOT NULL, `album_id` TEXT, `duration` INTEGER NOT NULL, `track_number` INTEGER NOT NULL, `disc_number` INTEGER NOT NULL, `year` INTEGER NOT NULL, `genre` TEXT, `bitRate` INTEGER, `mime_type` TEXT, `path` TEXT NOT NULL, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jellyfinId", + "columnName": "jellyfin_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackNumber", + "columnName": "track_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "bitRate", + "columnName": "bitRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_jellyfin_songs_jellyfin_id", + "unique": false, + "columnNames": [ + "jellyfin_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_jellyfin_id` ON `${TABLE_NAME}` (`jellyfin_id`)" + }, + { + "name": "index_jellyfin_songs_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + }, + { + "name": "index_jellyfin_songs_playlist_id_date_added", + "unique": false, + "columnNames": [ + "playlist_id", + "date_added" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)" + } + ] + }, + { + "tableName": "jellyfin_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songCount", + "columnName": "song_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncTime", + "columnName": "last_sync_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "listenbrainz_pending_listens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `listened_at_ms` INTEGER NOT NULL, `track_name` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `release_name` TEXT, `duration_ms` INTEGER, `recording_mbid` TEXT, `source` TEXT NOT NULL, `attempts` INTEGER NOT NULL DEFAULT 0, `created_at_ms` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listenedAtMs", + "columnName": "listened_at_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackName", + "columnName": "track_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseName", + "columnName": "release_name", + "affinity": "TEXT" + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "recordingMbid", + "columnName": "recording_mbid", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAtMs", + "columnName": "created_at_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "audio_bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `song_id` TEXT NOT NULL, `song_title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `album_art_uri` TEXT, `title` TEXT NOT NULL, `timestamp_ms` INTEGER NOT NULL, `created_time` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songTitle", + "columnName": "song_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtUri", + "columnName": "album_art_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestampMs", + "columnName": "timestamp_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdTime", + "columnName": "created_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "offline_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`download_id` TEXT NOT NULL, `attempt_id` TEXT NOT NULL, `song_id` TEXT NOT NULL, `source_uri` TEXT NOT NULL, `provider` TEXT NOT NULL, `title` TEXT NOT NULL, `mime_type` TEXT, `local_path` TEXT, `state` TEXT NOT NULL, `bytes_downloaded` INTEGER NOT NULL, `total_bytes` INTEGER, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `error_message` TEXT, PRIMARY KEY(`download_id`))", + "fields": [ + { + "fieldPath": "downloadId", + "columnName": "download_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attempt_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceUri", + "columnName": "source_uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bytesDownloaded", + "columnName": "bytes_downloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalBytes", + "columnName": "total_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "errorMessage", + "columnName": "error_message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "download_id" + ] + }, + "indices": [ + { + "name": "index_offline_tracks_source_uri", + "unique": true, + "columnNames": [ + "source_uri" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_offline_tracks_source_uri` ON `${TABLE_NAME}` (`source_uri`)" + }, + { + "name": "index_offline_tracks_song_id", + "unique": false, + "columnNames": [ + "song_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_offline_tracks_song_id` ON `${TABLE_NAME}` (`song_id`)" + }, + { + "name": "index_offline_tracks_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_offline_tracks_state` ON `${TABLE_NAME}` (`state`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2421f7b88e49bbf7b646386861b5e504')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/LocalPlaylistDaoTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/LocalPlaylistDaoTest.kt new file mode 100644 index 00000000..8e76c001 --- /dev/null +++ b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/LocalPlaylistDaoTest.kt @@ -0,0 +1,50 @@ +package com.lostf1sh.pixelplayeross.data.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LocalPlaylistDaoTest { + + private lateinit var database: PixelPlayerDatabase + private lateinit var playlistDao: LocalPlaylistDao + + @Before + fun createDatabase() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, PixelPlayerDatabase::class.java) + .allowMainThreadQueries() + .build() + playlistDao = database.localPlaylistDao() + } + + @After + fun closeDatabase() { + database.close() + } + + @Test + fun repeatedTracksRemainAtEachPlaylistPosition() = runTest { + playlistDao.replacePlaylistSongs( + playlistId = "playlist-1", + songIds = listOf("intro", "chorus", "chorus", "outro"), + ) + + val storedIds = playlistDao.observePlaylistSongs("playlist-1") + .first() + .map(PlaylistSongEntity::songId) + + assertThat(storedIds) + .containsExactly("intro", "chorus", "chorus", "outro") + .inOrder() + } +} diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/PlaylistMigrationTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/PlaylistMigrationTest.kt new file mode 100644 index 00000000..247e9092 --- /dev/null +++ b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/PlaylistMigrationTest.kt @@ -0,0 +1,66 @@ +package com.lostf1sh.pixelplayeross.data.database + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import java.io.IOException +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlaylistMigrationTest { + + @get:Rule + val migrationHelper = MigrationTestHelper( + instrumentation = InstrumentationRegistry.getInstrumentation(), + databaseClass = PixelPlayerDatabase::class.java, + ) + + @Test + @Throws(IOException::class) + fun migrationFromFiveToSixPreservesRowsAndAllowsRepeatedSongs() { + migrationHelper.createDatabase(DATABASE_NAME, 5).apply { + execSQL( + "INSERT INTO playlist_songs (playlist_id, song_id, sort_order) VALUES " + + "('playlist-1', 'intro', 0), " + + "('playlist-1', 'chorus', 1), " + + "('playlist-1', 'outro', 2)" + ) + close() + } + + migrationHelper.runMigrationsAndValidate( + name = DATABASE_NAME, + version = 6, + validateDroppedTables = true, + MIGRATION_5_6, + ).use { database -> + database.execSQL( + "INSERT INTO playlist_songs (playlist_id, song_id, sort_order) " + + "VALUES ('playlist-1', 'chorus', 3)" + ) + + assertThat(database.playlistSongIds("playlist-1")) + .containsExactly("intro", "chorus", "outro", "chorus") + .inOrder() + } + } + + private fun SupportSQLiteDatabase.playlistSongIds(playlistId: String): List { + return query( + "SELECT song_id FROM playlist_songs WHERE playlist_id = ? ORDER BY sort_order", + arrayOf(playlistId), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + } + + private companion object { + const val DATABASE_NAME = "playlist-migration-test" + } +} diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/service/MusicServiceWorkflowTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/service/MusicServiceWorkflowTest.kt index fc62926e..dd54a90b 100644 --- a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/service/MusicServiceWorkflowTest.kt +++ b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/service/MusicServiceWorkflowTest.kt @@ -23,6 +23,7 @@ import com.lostf1sh.pixelplayeross.data.database.MIGRATION_1_2 import com.lostf1sh.pixelplayeross.data.database.MIGRATION_2_3 import com.lostf1sh.pixelplayeross.data.database.MIGRATION_3_4 import com.lostf1sh.pixelplayeross.data.database.MIGRATION_4_5 +import com.lostf1sh.pixelplayeross.data.database.MIGRATION_5_6 import com.lostf1sh.pixelplayeross.data.database.MusicDao import com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase import com.lostf1sh.pixelplayeross.data.database.SongEntity @@ -74,7 +75,13 @@ class MusicServiceWorkflowTest { DATABASE_NAME, ) .addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback()) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + ) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .fallbackToDestructiveMigration(dropAllTables = true) .build() diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt index 59666759..c44b5cf0 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt @@ -15,6 +15,7 @@ import coil.ImageLoader import coil.ImageLoaderFactory import com.lostf1sh.pixelplayeross.data.diagnostics.AdvancedPerformanceDiagnosticsController import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository +import com.lostf1sh.pixelplayeross.data.playlist.M3uSyncCoordinator import com.lostf1sh.pixelplayeross.data.repository.ArtistImageRepository import com.lostf1sh.pixelplayeross.presentation.viewmodel.LibraryStateHolder import com.lostf1sh.pixelplayeross.presentation.viewmodel.ThemeStateHolder @@ -68,6 +69,9 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. @Inject lateinit var advancedPerformanceDiagnosticsController: dagger.Lazy + @Inject + lateinit var m3uSyncCoordinator: dagger.Lazy + private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) companion object { @@ -77,6 +81,12 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. private val appLifecycleObserver = object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { libraryStateHolder.get().restoreAfterTrimIfNeeded() + m3uSyncCoordinator.get().onAppForeground() + advancedPerformanceDiagnosticsController.get().onAppForeground() + } + + override fun onStop(owner: LifecycleOwner) { + advancedPerformanceDiagnosticsController.get().onAppBackground() } } @@ -111,6 +121,8 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. syncManager.get().start() + m3uSyncCoordinator.get().start() + advancedPerformanceDiagnosticsController.get().start(startupScope) startupScope.launch { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt index ce3393a1..0ef9d3db 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt @@ -153,3 +153,49 @@ val MIGRATION_4_5 = object : Migration(4, 5) { ) } } + +/** + * v5 -> v6: playlist positions are unique, while song ids may repeat. + * + * M3U playlists can intentionally contain the same track more than once. The previous + * `(playlist_id, song_id)` primary key silently collapsed those entries during persistence. + * Existing rows are copied in their current order and assigned dense positions so even legacy + * databases with duplicate sort values migrate without dropping a song. + */ +val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS `playlist_songs_v6`") + db.execSQL( + """ + CREATE TABLE `playlist_songs_v6` ( + `playlist_id` TEXT NOT NULL, + `song_id` TEXT NOT NULL, + `sort_order` INTEGER NOT NULL, + PRIMARY KEY(`playlist_id`, `sort_order`) + ) + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO `playlist_songs_v6` (`playlist_id`, `song_id`, `sort_order`) + SELECT + `playlist_id`, + `song_id`, + ROW_NUMBER() OVER ( + PARTITION BY `playlist_id` + ORDER BY `sort_order`, `rowid` + ) - 1 + FROM `playlist_songs` + """.trimIndent() + ) + db.execSQL("DROP TABLE `playlist_songs`") + db.execSQL("ALTER TABLE `playlist_songs_v6` RENAME TO `playlist_songs`") + db.execSQL( + "CREATE INDEX `index_playlist_songs_playlist_id_sort_order` " + + "ON `playlist_songs` (`playlist_id`, `sort_order`)" + ) + db.execSQL( + "CREATE INDEX `index_playlist_songs_song_id` ON `playlist_songs` (`song_id`)" + ) + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt index 9d94c71f..61974a4b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt @@ -27,7 +27,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase AudioBookmarkEntity::class, OfflineTrackEntity::class ], - version = 5, + version = 6, exportSchema = true ) abstract class PixelPlayerDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PlaylistSongEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PlaylistSongEntity.kt index d7c9aab2..b32d9285 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PlaylistSongEntity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PlaylistSongEntity.kt @@ -6,7 +6,7 @@ import androidx.room.Index @Entity( tableName = "playlist_songs", - primaryKeys = ["playlist_id", "song_id"], + primaryKeys = ["playlist_id", "sort_order"], indices = [ Index(value = ["playlist_id", "sort_order"]), Index(value = ["song_id"]) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uManager.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uManager.kt index c41b3487..500444fc 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uManager.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uManager.kt @@ -6,11 +6,38 @@ import com.lostf1sh.pixelplayeross.data.model.Playlist import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.repository.MusicRepository import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.BufferedReader -import java.io.InputStreamReader +import java.io.IOException +import java.io.Reader import javax.inject.Inject import javax.inject.Singleton +internal const val MAX_M3U_CHARACTERS = 8 * 1024 * 1024 + +internal fun readM3uTextBounded( + reader: Reader, + maxCharacters: Int = MAX_M3U_CHARACTERS, +): String { + require(maxCharacters >= 0) { "maxCharacters must not be negative" } + val buffer = CharArray(DEFAULT_BUFFER_SIZE) + val result = StringBuilder(minOf(maxCharacters, DEFAULT_BUFFER_SIZE)) + while (true) { + val count = reader.read(buffer) + if (count < 0) break + if (count > maxCharacters - result.length) { + throw IOException("Playlist file is too large") + } + result.append(buffer, 0, count) + } + return result.toString() +} + +data class M3uParseResult( + val playlistId: String?, + val songIds: List, + val unresolvedEntries: List, + val ambiguousEntries: List, +) + @Singleton class M3uManager @Inject constructor( @ApplicationContext private val context: Context, @@ -18,38 +45,13 @@ class M3uManager @Inject constructor( ) { suspend fun parseM3u(uri: Uri): Pair> { - val songIds = mutableListOf() var playlistName = "Imported Playlist" - val allSongs = musicRepository.getAllSongsOnce() - - val songsByPath = allSongs.associateBy { it.path } - val songsByFileName = allSongs.groupBy { it.path.substringAfterLast("/") } - val songsByContentUriFileName = allSongs.groupBy { it.contentUriString.substringAfterLast("/") } - - context.contentResolver.openInputStream(uri)?.use { inputStream -> - BufferedReader(InputStreamReader(inputStream)).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - val trimmedLine = line?.trim() ?: continue - if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) { - continue - } - - val songByPath = songsByPath[trimmedLine] - if (songByPath != null) { - songIds.add(songByPath.id) - } else { - val fileName = trimmedLine.substringAfterLast("/") - val matchedSong = songsByFileName[fileName]?.firstOrNull() - ?: songsByContentUriFileName[fileName]?.firstOrNull() - if (matchedSong != null) { - songIds.add(matchedSong.id) - } - } - } - } - } + val content = context.contentResolver.openInputStream(uri) + ?.bufferedReader(Charsets.UTF_8) + ?.use(::readM3uTextBounded) + .orEmpty() + val parsed = parseContent(content, allSongs) context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) @@ -58,16 +60,91 @@ class M3uManager @Inject constructor( } } - return Pair(playlistName, songIds) + return Pair(playlistName, parsed.songIds) } fun generateM3u(playlist: Playlist, songs: List): String { - val sb = StringBuilder() - sb.append("#EXTM3U\n") - for (song in songs) { - sb.append("#EXTINF:${song.duration / 1000},${song.artist} - ${song.title}\n") - sb.append("${song.path}\n") + return generateContent(playlist, songs) + } + + companion object { + private const val PLAYLIST_ID_MARKER = "#PIXELPLAYER-PLAYLIST-ID:" + + fun parseContent(content: String, songs: List): M3uParseResult { + val exactMatches = songs + .flatMap { song -> + listOf(song.path, song.contentUriString) + .filter(String::isNotBlank) + .map { entry -> normalizeEntry(entry) to song } + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, matches) -> matches.distinctBy(Song::id) } + val basenameMatches = songs + .flatMap { song -> + listOf(song.path, song.contentUriString) + .filter(String::isNotBlank) + .map { entry -> normalizeEntry(entry).substringAfterLast('/') to song } + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, matches) -> matches.distinctBy(Song::id) } + + var playlistId: String? = null + val resolvedIds = mutableListOf() + val unresolved = mutableListOf() + val ambiguous = mutableListOf() + + content.lineSequence().forEach { rawLine -> + val line = rawLine.removePrefix("\uFEFF").trim() + if (line.isBlank()) return@forEach + if (line.startsWith(PLAYLIST_ID_MARKER)) { + playlistId = line.removePrefix(PLAYLIST_ID_MARKER) + .trim() + .takeIf { it.isNotBlank() && it.length <= 200 } + return@forEach + } + if (line.startsWith('#')) return@forEach + + val normalized = normalizeEntry(line) + val exact = exactMatches[normalized].orEmpty() + if (exact.size == 1) { + resolvedIds += exact.single().id + return@forEach + } + if (exact.size > 1) { + ambiguous += line + return@forEach + } + + val basename = normalized.substringAfterLast('/') + val candidates = basenameMatches[basename].orEmpty() + when (candidates.size) { + 1 -> resolvedIds += candidates.single().id + 0 -> unresolved += line + else -> ambiguous += line + } + } + + return M3uParseResult( + playlistId = playlistId, + songIds = resolvedIds.toList(), + unresolvedEntries = unresolved, + ambiguousEntries = ambiguous, + ) } - return sb.toString() + + fun generateContent(playlist: Playlist, songs: List): String { + val sb = StringBuilder() + sb.append("#EXTM3U\n") + sb.append(PLAYLIST_ID_MARKER).append(playlist.id).append('\n') + for (song in songs) { + sb.append("#EXTINF:${song.duration / 1000},${song.artist} - ${song.title}\n") + val location = song.path.takeIf { it.isNotBlank() } ?: song.contentUriString + if (location.isNotBlank()) sb.append(location).append('\n') + } + return sb.toString() + } + + private fun normalizeEntry(value: String): String = + value.trim().replace('\\', '/') } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncPlanner.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncPlanner.kt new file mode 100644 index 00000000..11fc8e2e --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncPlanner.kt @@ -0,0 +1,43 @@ +package com.lostf1sh.pixelplayeross.data.playlist + +import kotlinx.serialization.Serializable + +internal enum class M3uSyncAction { + NONE, + EXPORT, + IMPORT, + CONFLICT, +} + +@Serializable +data class M3uSyncCheckpoint( + val appHash: String, + val fileHash: String, +) + +internal object M3uSyncPlanner { + fun decide( + appHash: String?, + fileHash: String?, + checkpoint: M3uSyncCheckpoint?, + ): M3uSyncAction { + if (appHash == null && fileHash == null) return M3uSyncAction.NONE + if (appHash != null && fileHash == null) return M3uSyncAction.EXPORT + if (appHash == null) return M3uSyncAction.IMPORT + // Independently edited sides can converge on the exact same representation. + if (appHash == fileHash) return M3uSyncAction.NONE + + if (checkpoint == null) { + return M3uSyncAction.CONFLICT + } + + val appChanged = appHash != checkpoint.appHash + val fileChanged = fileHash != checkpoint.fileHash + return when { + appChanged && fileChanged -> M3uSyncAction.CONFLICT + appChanged -> M3uSyncAction.EXPORT + fileChanged -> M3uSyncAction.IMPORT + else -> M3uSyncAction.NONE + } + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncRepository.kt new file mode 100644 index 00000000..2e0f2f53 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncRepository.kt @@ -0,0 +1,770 @@ +package com.lostf1sh.pixelplayeross.data.playlist + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import com.lostf1sh.pixelplayeross.data.model.Playlist +import com.lostf1sh.pixelplayeross.data.model.Song +import com.lostf1sh.pixelplayeross.data.model.isSmartPlaylist +import com.lostf1sh.pixelplayeross.data.preferences.M3uSyncConfig +import com.lostf1sh.pixelplayeross.data.preferences.M3uSyncLink +import com.lostf1sh.pixelplayeross.data.preferences.M3uSyncPreferences +import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository +import com.lostf1sh.pixelplayeross.data.repository.MusicRepository +import com.lostf1sh.pixelplayeross.di.AppScope +import com.lostf1sh.pixelplayeross.di.DispatcherProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.IOException +import java.security.MessageDigest +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber + +data class M3uSyncReport( + val exported: Int = 0, + val imported: Int = 0, + val unchanged: Int = 0, + val conflicts: List = emptyList(), + val unresolvedEntries: Int = 0, + val skippedFiles: Int = 0, +) { + val changed: Int get() = exported + imported +} + +data class M3uSyncState( + val enabled: Boolean = false, + val treeUri: String? = null, + val isSyncing: Boolean = false, + val lastSyncEpochMillis: Long? = null, + val lastReport: M3uSyncReport? = null, + val error: String? = null, +) + +private data class RuntimeState( + val isSyncing: Boolean = false, + val lastSyncEpochMillis: Long? = null, + val report: M3uSyncReport? = null, + val error: String? = null, +) + +private data class M3uDocument( + val uri: Uri, + val name: String, +) + +private data class ParsedM3uDocument( + val document: M3uDocument, + val content: String, + val parsed: M3uParseResult, +) { + val rawHash: String by lazy { sha256("${document.name}\n$content") } +} + +internal object M3uSyncSafety { + fun safeRequestedPlaylistId( + markerId: String?, + occupiedPlaylistIds: Set, + ): String? = markerId?.takeIf { it !in occupiedPlaylistIds } + + fun canImportEntireFile(parsed: M3uParseResult): Boolean = + parsed.unresolvedEntries.isEmpty() && parsed.ambiguousEntries.isEmpty() + + fun missingPlaylistSongIds( + playlistSongIds: List, + availableSongIds: Set, + ): List = playlistSongIds + .filterNot(availableSongIds::contains) + .distinct() +} + +internal data class M3uReplacementNames( + val temporary: String, + val backup: String, +) + +internal fun m3uReplacementNames( + desiredName: String, + transactionId: String, +): M3uReplacementNames { + val stem = desiredName.substringBeforeLast('.', desiredName) + .take(48) + .ifBlank { "Playlist" } + val extension = desiredName.substringAfterLast('.', "m3u8") + .takeIf { it.equals("m3u", ignoreCase = true) || it.equals("m3u8", ignoreCase = true) } + ?.lowercase() + ?: "m3u8" + val safeTransactionId = transactionId + .replace(Regex("[^A-Za-z0-9_-]"), "_") + .take(48) + .ifBlank { "write" } + return M3uReplacementNames( + temporary = "$stem.pixelplayer-$safeTransactionId.tmp", + backup = "$stem.pixelplayer-backup-$safeTransactionId.$extension", + ) +} + +/** + * Reconciles the local playlist database with one user-selected Storage Access Framework tree. + * SAF traversal, stable identity markers, conflict detection and checkpoints stay behind this + * boundary so UI and workers only need to request a sync. + */ +@Singleton +class M3uSyncRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val playlistRepository: PlaylistPreferencesRepository, + private val musicRepository: MusicRepository, + private val preferences: M3uSyncPreferences, + private val dispatchers: DispatcherProvider, + @AppScope appScope: CoroutineScope, +) { + private val resolver: ContentResolver get() = context.contentResolver + private val syncMutex = Mutex() + private val runtimeState = MutableStateFlow(RuntimeState()) + + val state: StateFlow = combine( + preferences.configFlow, + runtimeState, + ) { config, runtime -> + M3uSyncState( + enabled = config.treeUri != null, + treeUri = config.treeUri, + isSyncing = runtime.isSyncing, + lastSyncEpochMillis = runtime.lastSyncEpochMillis, + lastReport = runtime.report, + error = runtime.error, + ) + }.stateIn(appScope, SharingStarted.Eagerly, M3uSyncState()) + + suspend fun configure(treeUri: Uri): M3uSyncReport = syncMutex.withLock { + require(DocumentsContract.isTreeUri(treeUri)) { "A document tree must be selected" } + val selectedTree = treeUri.toString() + val previousTree = preferences.snapshot().treeUri?.let(Uri::parse) + var grantTaken = false + var selectionPersisted = false + try { + resolver.takePersistableUriPermission(treeUri, TREE_PERMISSION_FLAGS) + grantTaken = true + // Verify the grant before persisting it. Some providers advertise a tree but deny access. + withContext(dispatchers.io) { listM3uDocuments(treeUri) } + preferences.selectTree(selectedTree) + selectionPersisted = true + syncNowLocked() + } finally { + when { + selectionPersisted && previousTree != null && previousTree != treeUri -> { + releaseTreePermission(previousTree) + } + !selectionPersisted && grantTaken && previousTree?.toString() != selectedTree -> { + releaseTreePermission(treeUri) + } + } + } + } + + suspend fun disable(): Unit = syncMutex.withLock { + val oldTree = preferences.snapshot().treeUri?.let(Uri::parse) + preferences.disable() + runtimeState.value = RuntimeState() + oldTree?.let(::releaseTreePermission) + } + + suspend fun syncNow(): M3uSyncReport = syncMutex.withLock { + syncNowLocked() + } + + private suspend fun syncNowLocked(): M3uSyncReport { + val config = preferences.snapshot() + val treeUri = config.treeUri?.let(Uri::parse) + ?: return M3uSyncReport() + runtimeState.value = runtimeState.value.copy(isSyncing = true, error = null) + return try { + val report = withContext(dispatchers.io) { reconcile(treeUri, config) } + runtimeState.value = RuntimeState( + lastSyncEpochMillis = System.currentTimeMillis(), + report = report, + ) + report + } catch (error: Throwable) { + Timber.e(error, "Automatic M3U synchronization failed") + runtimeState.value = runtimeState.value.copy( + isSyncing = false, + error = error.message ?: error.javaClass.simpleName, + ) + throw error + } + } + + private suspend fun reconcile( + treeUri: Uri, + config: M3uSyncConfig, + ): M3uSyncReport { + val expectedTreeUri = checkNotNull(config.treeUri) + val songs = musicRepository.getAllSongsOnce() + val songsById = songs.associateBy(Song::id) + val exportableSongIds = songs.asSequence() + .filter { song -> song.path.isNotBlank() || song.contentUriString.isNotBlank() } + .map(Song::id) + .toSet() + val allPlaylistsById = playlistRepository.getPlaylistsOnce() + .associateBy(Playlist::id) + .toMutableMap() + val occupiedPlaylistIds = allPlaylistsById.keys.toMutableSet() + val playlists = allPlaylistsById.values + .filter(::isSyncablePlaylist) + .associateBy(Playlist::id) + .toMutableMap() + val documents = listM3uDocuments(treeUri) + val parsedFiles = documents.mapNotNull { document -> + runCatching { + val content = readBoundedText(document.uri) + ParsedM3uDocument(document, content, M3uManager.parseContent(content, songs)) + }.onFailure { error -> + Timber.w(error, "Skipping unreadable M3U document %s", document.name) + }.getOrNull() + } + val filesByUri = parsedFiles.associateBy { it.document.uri.toString() } + val unreadableDocuments = documents + .filterNot { it.uri.toString() in filesByUri } + val unreadableDocumentsByName = unreadableDocuments.groupBy { it.name.lowercase() } + val filesByMarker = parsedFiles + .mapNotNull { file -> file.parsed.playlistId?.let { id -> id to file } } + .groupBy({ it.first }, { it.second }) + + val links = config.links.toMutableMap() + val processedPlaylists = mutableSetOf() + val processedFiles = mutableSetOf() + val conflicts = mutableListOf() + var exported = 0 + var imported = 0 + var unchanged = 0 + var unresolved = 0 + val skipped = documents.size - parsedFiles.size + + fun noteFile(file: ParsedM3uDocument) { + processedFiles += file.document.uri.toString() + unresolved += file.parsed.unresolvedEntries.size + file.parsed.ambiguousEntries.size + } + + fun noteConflict( + name: String, + playlistId: String? = null, + files: List = emptyList(), + ) { + conflicts += name + playlistId?.let(processedPlaylists::add) + files.forEach(::noteFile) + } + + fun resolvedSongs(playlist: Playlist): List? { + val missing = M3uSyncSafety.missingPlaylistSongIds( + playlistSongIds = playlist.songIds, + availableSongIds = exportableSongIds, + ) + if (missing.isNotEmpty()) { + Timber.w( + "Refusing to export playlist %s because %d song ids are unavailable", + playlist.id, + missing.size, + ) + return null + } + return playlist.songIds.map(songsById::getValue) + } + + suspend fun importFile( + file: ParsedM3uDocument, + existing: Playlist?, + requestedId: String? = null, + ): Playlist { + if (!M3uSyncSafety.canImportEntireFile(file.parsed)) { + throw IOException("Playlist contains unresolved or ambiguous entries") + } + val fileName = playlistNameFromFile(file.document.name) + val playlist = if (existing == null) { + occupiedPlaylistIds += playlistRepository.getPlaylistsOnce().map(Playlist::id) + val safeRequestedId = M3uSyncSafety.safeRequestedPlaylistId( + markerId = requestedId, + occupiedPlaylistIds = occupiedPlaylistIds, + ) + if (requestedId != null && safeRequestedId == null) { + throw IOException("Playlist identity is already in use") + } + playlistRepository.createPlaylist( + name = fileName, + songIds = file.parsed.songIds, + customId = safeRequestedId, + source = "LOCAL", + ) + } else { + val updated = existing.copy(name = fileName, songIds = file.parsed.songIds) + playlistRepository.updatePlaylist(updated) + updated + } + playlists[playlist.id] = playlist + allPlaylistsById[playlist.id] = playlist + occupiedPlaylistIds += playlist.id + val completeSongs = resolvedSongs(playlist) + ?: throw IOException("Imported playlist songs are unavailable") + val appHash = appHash(playlist, completeSongs) + links[playlist.id] = M3uSyncLink( + playlistId = playlist.id, + documentUri = file.document.uri.toString(), + fileName = file.document.name, + checkpoint = M3uSyncCheckpoint(appHash, file.rawHash), + ) + processedPlaylists += playlist.id + noteFile(file) + imported++ + return playlist + } + + suspend fun exportPlaylist( + playlist: Playlist, + completeSongs: List, + currentFile: ParsedM3uDocument?, + ) { + val desiredName = desiredFileName(playlist.name) + val content = M3uManager.generateContent(playlist, completeSongs) + val written = writeDocument(treeUri, currentFile?.document, desiredName, content) + val finalFileHash = sha256("${written.name}\n$content") + val finalAppHash = appHash(playlist, completeSongs) + links[playlist.id] = M3uSyncLink( + playlistId = playlist.id, + documentUri = written.uri.toString(), + fileName = written.name, + checkpoint = M3uSyncCheckpoint(finalAppHash, finalFileHash), + ) + currentFile?.let(::noteFile) + processedFiles += written.uri.toString() + processedPlaylists += playlist.id + exported++ + } + + // Reconcile known links first. A marker can recover a file whose provider changed its URI. + config.links.values.forEach linkLoop@ { link -> + val playlist = playlists[link.playlistId] + val occupyingPlaylist = allPlaylistsById[link.playlistId] + val unreadableLinkedDocuments = listOfNotNull( + unreadableDocuments.firstOrNull { it.uri.toString() == link.documentUri }, + ) + .plus(unreadableDocumentsByName[link.fileName.lowercase()].orEmpty()) + .distinctBy { it.uri } + if (unreadableLinkedDocuments.isNotEmpty()) { + noteConflict( + name = playlist?.name ?: occupyingPlaylist?.name ?: link.fileName, + playlistId = playlist?.id, + ) + return@linkLoop + } + val markerFiles = filesByMarker[link.playlistId].orEmpty() + .filterNot { it.document.uri.toString() in processedFiles } + val configuredFile = filesByUri[link.documentUri] + val configuredFileUri = configuredFile?.document?.uri?.toString() + if (configuredFileUri != null && configuredFileUri in processedFiles) { + noteConflict( + name = playlist?.name ?: occupyingPlaylist?.name ?: link.fileName, + playlistId = playlist?.id, + ) + return@linkLoop + } + val linkedFile = configuredFile + ?.takeIf { linkedFile -> + linkedFile.document.uri.toString() !in processedFiles && + ( + linkedFile.parsed.playlistId == null || + linkedFile.parsed.playlistId == link.playlistId + ) + } + if (linkedFile == null && markerFiles.size > 1) { + noteConflict( + name = playlist?.name ?: occupyingPlaylist?.name ?: link.fileName, + playlistId = playlist?.id, + files = markerFiles, + ) + return@linkLoop + } + val file = linkedFile ?: markerFiles.singleOrNull() + + when { + playlist == null && file == null -> links.remove(link.playlistId) + playlist == null && file != null -> { + when { + occupyingPlaylist != null -> noteConflict( + name = occupyingPlaylist.name, + files = listOf(file), + ) + !M3uSyncSafety.canImportEntireFile(file.parsed) -> noteConflict( + name = playlistNameFromFile(file.document.name), + files = listOf(file), + ) + else -> importFile( + file = file, + existing = null, + requestedId = link.playlistId, + ) + } + } + playlist != null && file == null -> { + val completeSongs = resolvedSongs(playlist) + if (completeSongs == null) { + noteConflict(playlist.name, playlist.id) + } else { + exportPlaylist(playlist, completeSongs, currentFile = null) + } + } + playlist != null && file != null -> { + val completeSongs = resolvedSongs(playlist) + if (completeSongs == null) { + noteConflict(playlist.name, playlist.id, listOf(file)) + return@linkLoop + } + if (file.document.name.isM3uBackupFileName()) { + if ( + M3uSyncSafety.canImportEntireFile(file.parsed) && + file.parsed.songIds == playlist.songIds + ) { + exportPlaylist(playlist, completeSongs, file) + } else { + noteConflict(playlist.name, playlist.id, listOf(file)) + } + return@linkLoop + } + val appHash = appHash(playlist, completeSongs) + val action = M3uSyncPlanner.decide(appHash, file.rawHash, link.checkpoint) + if ( + action != M3uSyncAction.NONE && + !M3uSyncSafety.canImportEntireFile(file.parsed) + ) { + noteConflict(playlist.name, playlist.id, listOf(file)) + return@linkLoop + } + when (action) { + M3uSyncAction.NONE -> { + links[playlist.id] = link.copy( + documentUri = file.document.uri.toString(), + fileName = file.document.name, + checkpoint = M3uSyncCheckpoint(appHash, file.rawHash), + ) + processedPlaylists += playlist.id + noteFile(file) + unchanged++ + } + M3uSyncAction.EXPORT -> exportPlaylist(playlist, completeSongs, file) + M3uSyncAction.IMPORT -> importFile(file, playlist) + M3uSyncAction.CONFLICT -> noteConflict( + playlist.name, + playlist.id, + listOf(file), + ) + } + } + } + } + + // New app playlists either adopt their unique identity-marked file or get a new export. + playlists.values.filterNot { it.id in processedPlaylists }.forEach playlistLoop@ { playlist -> + if (unreadableDocumentsByName[desiredFileName(playlist.name).lowercase()].orEmpty().isNotEmpty()) { + noteConflict(playlist.name, playlist.id) + return@playlistLoop + } + val markerFiles = filesByMarker[playlist.id].orEmpty() + .filterNot { it.document.uri.toString() in processedFiles } + val completeSongs = resolvedSongs(playlist) + if (completeSongs == null) { + noteConflict(playlist.name, playlist.id, markerFiles) + return@playlistLoop + } + when { + markerFiles.size > 1 -> { + noteConflict(playlist.name, playlist.id, markerFiles) + } + markerFiles.size == 1 -> { + val file = markerFiles.single() + val sameSongs = M3uSyncSafety.canImportEntireFile(file.parsed) && + file.parsed.songIds == playlist.songIds + if (sameSongs) { + val appHash = appHash(playlist, completeSongs) + links[playlist.id] = M3uSyncLink( + playlistId = playlist.id, + documentUri = file.document.uri.toString(), + fileName = file.document.name, + checkpoint = M3uSyncCheckpoint(appHash, file.rawHash), + ) + processedPlaylists += playlist.id + noteFile(file) + unchanged++ + } else { + noteConflict(playlist.name, playlist.id, listOf(file)) + } + } + else -> exportPlaylist(playlist, completeSongs, currentFile = null) + } + } + + // Every complete remaining external file is imported. An occupied marker is never reused. + parsedFiles.filterNot { it.document.uri.toString() in processedFiles }.forEach { file -> + val markerId = file.parsed.playlistId + val occupyingPlaylist = markerId?.let(allPlaylistsById::get) + when { + !M3uSyncSafety.canImportEntireFile(file.parsed) -> noteConflict( + playlistNameFromFile(file.document.name), + files = listOf(file), + ) + markerId != null && markerId in occupiedPlaylistIds -> noteConflict( + occupyingPlaylist?.name ?: playlistNameFromFile(file.document.name), + files = listOf(file), + ) + else -> importFile(file, existing = null, requestedId = markerId) + } + } + + if ( + !preferences.replaceLinksIfSelection( + expectedTreeUri = expectedTreeUri, + expectedRevision = config.revision, + links = links, + ) + ) { + throw IOException("Playlist sync folder changed during reconciliation") + } + return M3uSyncReport( + exported = exported, + imported = imported, + unchanged = unchanged, + conflicts = conflicts.distinct(), + unresolvedEntries = unresolved, + skippedFiles = skipped.coerceAtLeast(0), + ) + } + + private fun isSyncablePlaylist(playlist: Playlist): Boolean = + !playlist.isQueueGenerated && !playlist.isSmartPlaylist && playlist.source == "LOCAL" + + private fun appHash(playlist: Playlist, completeSongs: List): String { + val name = desiredFileName(playlist.name) + val content = M3uManager.generateContent(playlist, completeSongs) + return sha256("$name\n$content") + } + + private fun listM3uDocuments(treeUri: Uri): List { + val treeDocumentId = DocumentsContract.getTreeDocumentId(treeUri) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, treeDocumentId) + val result = mutableListOf() + resolver.query( + childrenUri, + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + ), + null, + null, + null, + )?.use { cursor -> + val idIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + while (cursor.moveToNext()) { + val mime = cursor.getString(mimeIndex) + val name = cursor.getString(nameIndex) ?: continue + if (mime == DocumentsContract.Document.MIME_TYPE_DIR || !name.isM3uFileName()) continue + result += M3uDocument( + uri = DocumentsContract.buildDocumentUriUsingTree( + treeUri, + cursor.getString(idIndex), + ), + name = name, + ) + } + } ?: throw IOException("The selected playlist folder is unavailable") + return result + } + + private fun readBoundedText(uri: Uri): String { + val reader = resolver.openInputStream(uri)?.bufferedReader(Charsets.UTF_8) + ?: throw IOException("Unable to open playlist file") + return reader.use { readM3uTextBounded(it) } + } + + private fun writeDocument( + treeUri: Uri, + current: M3uDocument?, + desiredName: String, + content: String, + ): M3uDocument { + val names = m3uReplacementNames(desiredName, UUID.randomUUID().toString()) + val temporary = createDocument(treeUri, names.temporary) + var temporaryUriForCleanup: Uri? = temporary.uri + + try { + if (temporary.name.isM3uFileName()) { + throw IOException("Storage provider changed the temporary playlist name") + } + writeAndClose(temporary.uri, content) + + if (current == null) { + val created = renameRequired(temporary, desiredName) + temporaryUriForCleanup = null + if (!created.name.isM3uFileName()) { + deleteBestEffort(created, "unrecognized replacement") + throw IOException("Storage provider changed the playlist file name") + } + return created + } + + val backup = renameRequired(current, names.backup) + var replacement: M3uDocument? = null + try { + replacement = renameRequired(temporary, desiredName) + temporaryUriForCleanup = null + if (!replacement.name.isM3uFileName()) { + throw IOException("Storage provider changed the playlist file name") + } + } catch (error: Exception) { + val restored = renameBestEffort(backup, current.name) + if (restored != null) { + replacement?.let { deleteBestEffort(it, "failed replacement") } + } else { + Timber.e( + error, + "Could not restore %s; original content remains at %s", + current.name, + backup.uri, + ) + } + throw error + } + + cleanupBackupAfterCommit(backup) + return checkNotNull(replacement) + } catch (error: Exception) { + temporaryUriForCleanup?.let { temporaryUri -> + deleteBestEffort(M3uDocument(temporaryUri, names.temporary), "temporary write") + } + when (error) { + is IOException -> throw error + is SecurityException -> throw error + else -> throw IOException("Unable to replace playlist file safely", error) + } + } + } + + private fun createDocument(treeUri: Uri, displayName: String): M3uDocument { + val rootDocumentUri = DocumentsContract.buildDocumentUriUsingTree( + treeUri, + DocumentsContract.getTreeDocumentId(treeUri), + ) + val created = DocumentsContract.createDocument( + resolver, + rootDocumentUri, + M3U_MIME_TYPE, + displayName, + ) ?: throw IOException("Unable to create temporary playlist file") + return M3uDocument(created, displayName(created, displayName)) + } + + private fun writeAndClose(uri: Uri, content: String) { + val output = runCatching { resolver.openOutputStream(uri, "wt") }.getOrNull() + ?: resolver.openOutputStream(uri, "w") + ?: throw IOException("Unable to open temporary playlist file") + output.bufferedWriter(Charsets.UTF_8).use { writer -> + writer.write(content) + } + } + + private fun renameRequired(document: M3uDocument, desiredName: String): M3uDocument { + val renamed = DocumentsContract.renameDocument(resolver, document.uri, desiredName) + ?: throw IOException("Storage provider cannot safely rename playlist files") + return M3uDocument(renamed, displayName(renamed, desiredName)) + } + + private fun renameBestEffort(document: M3uDocument, desiredName: String): M3uDocument? = + runCatching { renameRequired(document, desiredName) } + .onFailure { error -> + Timber.e(error, "Unable to rename playlist document %s", document.uri) + } + .getOrNull() + + private fun cleanupBackupAfterCommit(backup: M3uDocument) { + if (deleteBestEffort(backup, "committed backup")) return + val hiddenName = backup.name.substringBeforeLast('.', backup.name) + .take(100) + .plus(".bak") + val hidden = renameBestEffort(backup, hiddenName) + if (hidden == null) { + Timber.w("Original playlist backup remains recoverable at %s", backup.uri) + } else { + Timber.w("Original playlist backup remains recoverable at %s", hidden.uri) + } + } + + private fun deleteBestEffort(document: M3uDocument, purpose: String): Boolean = + runCatching { DocumentsContract.deleteDocument(resolver, document.uri) } + .onFailure { error -> + Timber.w(error, "Unable to remove %s document %s", purpose, document.uri) + } + .getOrDefault(false) + + private fun releaseTreePermission(treeUri: Uri) { + runCatching { + resolver.releasePersistableUriPermission(treeUri, TREE_PERMISSION_FLAGS) + }.onFailure { error -> + Timber.w(error, "Unable to release M3U tree permission %s", treeUri) + } + } + + private fun displayName(uri: Uri, fallback: String): String = runCatching { + resolver.query( + uri, + arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + val index = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null + } + }.getOrNull()?.takeIf(String::isNotBlank) ?: fallback + + private companion object { + const val M3U_MIME_TYPE = "audio/x-mpegurl" + val TREE_PERMISSION_FLAGS: Int = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + fun desiredFileName(playlistName: String): String { + val stem = playlistName + .replace(Regex("[\\u0000-\\u001F\\\\/:*?\"<>|]"), "_") + .trim(' ', '.') + .take(120) + .ifBlank { "Playlist" } + return "$stem.m3u8" + } + + fun playlistNameFromFile(fileName: String): String = fileName + .substringBeforeLast('.', fileName) + .substringBefore(".pixelplayer-backup-") + .trim() + .ifBlank { "Imported Playlist" } + + fun String.isM3uFileName(): Boolean = + endsWith(".m3u", ignoreCase = true) || endsWith(".m3u8", ignoreCase = true) + + fun String.isM3uBackupFileName(): Boolean = contains(".pixelplayer-backup-") + } +} + +private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncWorker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncWorker.kt new file mode 100644 index 00000000..2a558178 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncWorker.kt @@ -0,0 +1,141 @@ +package com.lostf1sh.pixelplayeross.data.playlist + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.lostf1sh.pixelplayeross.data.model.isSmartPlaylist +import com.lostf1sh.pixelplayeross.data.preferences.M3uSyncPreferences +import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository +import com.lostf1sh.pixelplayeross.di.AppScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.io.IOException +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import timber.log.Timber + +@HiltWorker +class M3uSyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val repository: M3uSyncRepository, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result = try { + repository.syncNow() + Result.success() + } catch (error: IOException) { + Timber.w(error, "M3U sync hit a temporary storage error") + if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure() + } catch (error: SecurityException) { + Timber.w(error, "M3U sync folder permission was revoked") + Result.failure() + } + + private companion object { + const val MAX_RETRIES = 3 + } +} + +/** Schedules periodic, foreground and playlist-change reconciliation without leaking UI state. */ +@Singleton +@OptIn(FlowPreview::class) +class M3uSyncCoordinator @Inject constructor( + private val workManager: WorkManager, + private val preferences: M3uSyncPreferences, + private val playlistRepository: PlaylistPreferencesRepository, + @AppScope private val scope: CoroutineScope, +) { + private val started = AtomicBoolean(false) + @Volatile + private var enabled = false + + fun start() { + if (!started.compareAndSet(false, true)) return + + scope.launch { + preferences.configFlow + .map { it.treeUri != null } + .distinctUntilChanged() + .collect { isEnabled -> + enabled = isEnabled + if (isEnabled) { + schedulePeriodic() + requestNow() + } else { + workManager.cancelUniqueWork(PERIODIC_WORK_NAME) + workManager.cancelUniqueWork(IMMEDIATE_WORK_NAME) + } + } + } + + scope.launch { + combine( + preferences.configFlow.map { it.treeUri != null }, + playlistRepository.userPlaylistsFlow.map { playlists -> + playlists + .filter { + !it.isQueueGenerated && !it.isSmartPlaylist && it.source == "LOCAL" + } + .map { playlist -> playlist.id to playlist.lastModified } + }, + ) { isEnabled, revision -> isEnabled to revision } + .debounce(PLAYLIST_CHANGE_DEBOUNCE_MS) + .distinctUntilChanged() + .collect { (isEnabled, _) -> + if (isEnabled) requestNow() + } + } + } + + fun onAppForeground() { + if (enabled) requestNow() + } + + fun requestNow() { + if (!enabled) return + workManager.enqueueUniqueWork( + IMMEDIATE_WORK_NAME, + // A request that arrives while a worker is running must become a durable follow-up. + ExistingWorkPolicy.APPEND_OR_REPLACE, + OneTimeWorkRequestBuilder() + .setConstraints(storageConstraints()) + .build(), + ) + } + + private fun schedulePeriodic() { + workManager.enqueueUniquePeriodicWork( + PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) + .setConstraints(storageConstraints()) + .build(), + ) + } + + private fun storageConstraints(): Constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .build() + + private companion object { + const val PERIODIC_WORK_NAME = "automatic_m3u_sync_periodic" + const val IMMEDIATE_WORK_NAME = "automatic_m3u_sync_immediate" + const val PLAYLIST_CHANGE_DEBOUNCE_MS = 1_500L + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/M3uSyncPreferences.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/M3uSyncPreferences.kt new file mode 100644 index 00000000..84e3d2ea --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/M3uSyncPreferences.kt @@ -0,0 +1,112 @@ +package com.lostf1sh.pixelplayeross.data.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.lostf1sh.pixelplayeross.data.playlist.M3uSyncCheckpoint +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +internal data class M3uSyncLink( + val playlistId: String, + val documentUri: String, + val fileName: String, + val checkpoint: M3uSyncCheckpoint? = null, +) + +@Serializable +internal data class M3uSyncConfig( + val treeUri: String? = null, + val links: Map = emptyMap(), + val revision: Long = 0, +) + +internal fun M3uSyncConfig.withLinksIfSelectionMatches( + expectedTreeUri: String, + expectedRevision: Long, + replacementLinks: Map, +): M3uSyncConfig? = if (treeUri == expectedTreeUri && revision == expectedRevision) { + copy(links = replacementLinks) +} else { + null +} + +/** + * Owns the complete persisted state for app-external M3U synchronization. + * + * Keeping the tree grant and reconciliation checkpoints in one serialized value means an update + * cannot expose a link without its matching checkpoint (or vice versa). + */ +@Singleton +class M3uSyncPreferences @Inject constructor( + private val dataStore: DataStore, + private val json: Json, +) { + internal val configFlow: Flow = dataStore.data.map { preferences -> + preferences[CONFIG_KEY] + ?.let { encoded -> runCatching { json.decodeFromString(encoded) }.getOrNull() } + ?: M3uSyncConfig() + } + + internal suspend fun snapshot(): M3uSyncConfig = configFlow.first() + + internal suspend fun selectTree(treeUri: String) { + update { current -> + if (current.treeUri == treeUri) { + current.copy(revision = current.revision + 1) + } else { + M3uSyncConfig(treeUri = treeUri, revision = current.revision + 1) + } + } + } + + internal suspend fun replaceLinksIfSelection( + expectedTreeUri: String, + expectedRevision: Long, + links: Map, + ): Boolean { + var committed = false + dataStore.edit { preferences -> + val current = preferences[CONFIG_KEY] + ?.let { encoded -> + runCatching { json.decodeFromString(encoded) }.getOrNull() + } + ?: M3uSyncConfig() + current.withLinksIfSelectionMatches( + expectedTreeUri = expectedTreeUri, + expectedRevision = expectedRevision, + replacementLinks = links, + )?.let { updated -> + preferences[CONFIG_KEY] = json.encodeToString(updated) + committed = true + } + } + return committed + } + + suspend fun disable() { + update { current -> M3uSyncConfig(revision = current.revision + 1) } + } + + private suspend fun update(transform: (M3uSyncConfig) -> M3uSyncConfig) { + dataStore.edit { preferences -> + val current = preferences[CONFIG_KEY] + ?.let { encoded -> + runCatching { json.decodeFromString(encoded) }.getOrNull() + } + ?: M3uSyncConfig() + preferences[CONFIG_KEY] = json.encodeToString(transform(current)) + } + } + + private companion object { + val CONFIG_KEY = stringPreferencesKey("m3u_sync_config_v1") + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt index 5b7b6c0f..d0f021d1 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt @@ -28,6 +28,7 @@ import com.lostf1sh.pixelplayeross.data.database.MIGRATION_1_2 import com.lostf1sh.pixelplayeross.data.database.MIGRATION_2_3 import com.lostf1sh.pixelplayeross.data.database.MIGRATION_3_4 import com.lostf1sh.pixelplayeross.data.database.MIGRATION_4_5 +import com.lostf1sh.pixelplayeross.data.database.MIGRATION_5_6 import com.lostf1sh.pixelplayeross.data.database.MusicDao import com.lostf1sh.pixelplayeross.data.database.OfflineTrackDao import com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase @@ -132,7 +133,13 @@ object AppModule { "pixelplayer_database" ) .addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback()) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + ) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) if (BuildConfig.DEBUG) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt index bc820068..b480a6f2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt @@ -176,6 +176,7 @@ import com.lostf1sh.pixelplayeross.presentation.model.SettingsCategory import com.lostf1sh.pixelplayeross.presentation.navigation.Screen import com.lostf1sh.pixelplayeross.presentation.settings.search.settingHighlight import com.lostf1sh.pixelplayeross.presentation.viewmodel.LyricsRefreshProgress +import com.lostf1sh.pixelplayeross.presentation.viewmodel.M3uSyncViewModel import com.lostf1sh.pixelplayeross.presentation.viewmodel.PlayerViewModel import com.lostf1sh.pixelplayeross.presentation.viewmodel.SettingsViewModel import com.lostf1sh.pixelplayeross.ui.theme.RoundedSans @@ -192,6 +193,7 @@ fun SettingsCategoryScreen( navController: NavController, playerViewModel: PlayerViewModel, settingsViewModel: SettingsViewModel = hiltViewModel(), + m3uSyncViewModel: M3uSyncViewModel = hiltViewModel(), statsViewModel: com.lostf1sh.pixelplayeross.presentation.viewmodel.StatsViewModel = hiltViewModel(), onBackClick: () -> Unit ) { @@ -211,6 +213,7 @@ fun SettingsCategoryScreen( val isSyncing by settingsViewModel.isSyncing.collectAsStateWithLifecycle() val syncProgress by settingsViewModel.syncProgress.collectAsStateWithLifecycle() val dataTransferProgress by settingsViewModel.dataTransferProgress.collectAsStateWithLifecycle() + val m3uSyncState by m3uSyncViewModel.state.collectAsStateWithLifecycle() val paletteRegenerateTargets by playerViewModel.paletteRegenerationTargets.collectAsStateWithLifecycle() val explorerRoot = settingsViewModel.explorerRoot() @@ -257,6 +260,12 @@ fun SettingsCategoryScreen( } } + val m3uFolderPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri -> + if (uri != null) m3uSyncViewModel.selectFolder(uri) + } + val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(settingsViewModel, lifecycleOwner) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -266,6 +275,14 @@ fun SettingsCategoryScreen( } } + LaunchedEffect(m3uSyncViewModel, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + m3uSyncViewModel.messages.collectLatest { message -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } + } + LaunchedEffect(isSyncing, refreshRequested) { if (!refreshRequested) return@LaunchedEffect @@ -503,6 +520,76 @@ fun SettingsCategoryScreen( ) } + SettingsSubsection(title = stringResource(R.string.setcat_m3u_sync_section)) { + SwitchSettingItem( + title = stringResource(R.string.setcat_m3u_sync_title), + subtitle = if (m3uSyncState.enabled) { + val folder = remember(m3uSyncState.treeUri) { + m3uSyncState.treeUri + ?.let(Uri::parse) + ?.lastPathSegment + ?.substringAfterLast(':') + .orEmpty() + } + stringResource( + R.string.setcat_m3u_sync_enabled_subtitle, + folder.ifBlank { stringResource(R.string.setcat_m3u_sync_selected_folder) }, + ) + } else { + stringResource(R.string.setcat_m3u_sync_disabled_subtitle) + }, + checked = m3uSyncState.enabled, + onCheckedChange = { enabled -> + if (enabled) m3uFolderPicker.launch(null) else m3uSyncViewModel.disable() + }, + leadingIcon = { + Icon( + Icons.Outlined.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + ) + }, + modifier = Modifier.settingHighlight("item_library_m3u_sync", highlightKey), + ) + if (m3uSyncState.enabled) { + SettingsItem( + title = stringResource(R.string.setcat_m3u_sync_now_title), + subtitle = when { + m3uSyncState.error != null -> m3uSyncState.error.orEmpty() + m3uSyncState.lastReport != null -> { + val report = m3uSyncState.lastReport!! + stringResource( + R.string.setcat_m3u_sync_result, + report.changed, + report.conflicts.size, + report.unresolvedEntries, + ) + } + else -> stringResource(R.string.setcat_m3u_sync_now_subtitle) + }, + leadingIcon = { + Icon( + Icons.Rounded.Restore, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + ) + }, + trailingIcon = { + if (m3uSyncState.isSyncing) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } + }, + modifier = Modifier.settingHighlight( + "item_library_m3u_sync_now", + highlightKey, + ), + onClick = { + if (!m3uSyncState.isSyncing) m3uSyncViewModel.syncNow() + }, + ) + } + } + SettingsSubsection(title = stringResource(R.string.setcat_online_services)) { SwitchSettingItem( title = stringResource(R.string.setcat_external_lyrics_title), diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt index da3ab86c..c5bb0834 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt @@ -66,6 +66,26 @@ object SettingsRegistry { type = SettingType.ACTION, keywordsStatic = listOf("scan", "rescan", "sync", "refresh", "rebuild", "database") ), + SettingSpec( + id = "library_m3u_sync", + itemKey = "item_library_m3u_sync", + titleRes = R.string.setcat_m3u_sync_title, + subtitleRes = R.string.setcat_m3u_sync_disabled_subtitle, + category = SettingsCategory.LIBRARY, + subscreenRoute = Screen.SettingsCategory.createRoute("library"), + type = SettingType.NAVIGABLE_CARD, + keywordsStatic = listOf("m3u", "m3u8", "playlist", "sync", "folder", "import", "export") + ), + SettingSpec( + id = "library_m3u_sync_now", + itemKey = "item_library_m3u_sync_now", + titleRes = R.string.setcat_m3u_sync_now_title, + subtitleRes = R.string.setcat_m3u_sync_now_subtitle, + category = SettingsCategory.LIBRARY, + subscreenRoute = Screen.SettingsCategory.createRoute("library"), + type = SettingType.ACTION, + keywordsStatic = listOf("m3u", "playlist", "sync now", "reconcile") + ), SettingSpec( id = "library_auto_scan_lrc", itemKey = "item_library_auto_scan_lrc", diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/M3uSyncViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/M3uSyncViewModel.kt new file mode 100644 index 00000000..4680acf4 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/M3uSyncViewModel.kt @@ -0,0 +1,50 @@ +package com.lostf1sh.pixelplayeross.presentation.viewmodel + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lostf1sh.pixelplayeross.data.playlist.M3uSyncRepository +import com.lostf1sh.pixelplayeross.data.playlist.M3uSyncState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class M3uSyncViewModel @Inject constructor( + private val repository: M3uSyncRepository, +) : ViewModel() { + val state: StateFlow = repository.state + + private val _messages = MutableSharedFlow(extraBufferCapacity = 1) + val messages: SharedFlow = _messages + + fun selectFolder(uri: Uri) { + viewModelScope.launch { + runCatching { repository.configure(uri) } + .onFailure { error -> + _messages.emit(error.message ?: "Unable to use the selected folder") + } + } + } + + fun syncNow() { + viewModelScope.launch { + runCatching { repository.syncNow() } + .onFailure { error -> + _messages.emit(error.message ?: "Playlist synchronization failed") + } + } + } + + fun disable() { + viewModelScope.launch { + runCatching { repository.disable() } + .onFailure { error -> + _messages.emit(error.message ?: "Unable to disable playlist synchronization") + } + } + } +} diff --git a/app/src/main/res/values-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml index d0cb427b..6e033861 100644 --- a/app/src/main/res/values-tr/strings_settings.xml +++ b/app/src/main/res/values-tr/strings_settings.xml @@ -1,5 +1,13 @@ + Çalma listesi eşitleme + Otomatik M3U eşitleme + Yerel çalma listelerini iki yönlü eşitlemek için harici bir klasör seçin + %1$s ile eşitleniyor + seçili klasör + Çalma listelerini şimdi eşitle + Çakışmaların üzerine yazmadan değişiklikleri içe ve dışa aktar + %1$d değişti · %2$d çakışma · %3$d çözülemeyen kayıt .pxpl Olarak Dışa Aktar Yedek Oluşturuluyor Yedek Geri Yükleniyor diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 38700452..feeb7469 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -1,5 +1,13 @@ - + + Playlist synchronization + Automatic M3U sync + Choose an external folder to keep local playlists in sync both ways + Synchronizing with %1$s + selected folder + Sync playlists now + Import and export changes without overwriting conflicts + %1$d changed · %2$d conflicts · %3$d unresolved entries Settings Accounts Manage Navidrome and Jellyfin services diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uManagerTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uManagerTest.kt new file mode 100644 index 00000000..a564c5e6 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uManagerTest.kt @@ -0,0 +1,120 @@ +package com.lostf1sh.pixelplayeross.data.playlist + +import com.lostf1sh.pixelplayeross.data.model.Playlist +import com.lostf1sh.pixelplayeross.data.model.Song +import com.google.common.truth.Truth.assertThat +import java.io.IOException +import java.io.StringReader +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class M3uManagerTest { + + @Test + fun `parser preserves marker order and reports ambiguous basenames`() { + val songs = listOf( + song("1", "/Music/Album/one.flac"), + song("2", "/Music/A/shared.flac"), + song("3", "/Music/B/shared.flac"), + ) + val content = """ + #EXTM3U + #PIXELPLAYER-PLAYLIST-ID:playlist-42 + /Music/Album/one.flac + shared.flac + missing.flac + """.trimIndent() + + val result = M3uManager.parseContent(content, songs) + + assertThat(result.playlistId).isEqualTo("playlist-42") + assertThat(result.songIds).containsExactly("1") + assertThat(result.ambiguousEntries).containsExactly("shared.flac") + assertThat(result.unresolvedEntries).containsExactly("missing.flac") + } + + @Test + fun `generator emits stable identity and cloud uri fallback`() { + val playlist = Playlist( + id = "playlist-42", + name = "Road Trip", + songIds = listOf("1", "cloud"), + ) + val songs = listOf( + song("1", "/Music/one.flac"), + song("cloud", "", contentUri = "navidrome://song-cloud"), + ) + + val content = M3uManager.generateContent(playlist, songs) + + assertThat(content).contains("#PIXELPLAYER-PLAYLIST-ID:playlist-42") + assertThat(content).contains("/Music/one.flac") + assertThat(content).contains("navidrome://song-cloud") + } + + @Test + fun `duplicate exact locations are ambiguous instead of picking an arbitrary song`() { + val songs = listOf( + song("1", "/Music/duplicate.flac"), + song("2", "/Music/duplicate.flac"), + ) + + val result = M3uManager.parseContent("/Music/duplicate.flac", songs) + + assertThat(result.songIds).isEmpty() + assertThat(result.ambiguousEntries).containsExactly("/Music/duplicate.flac") + } + + @Test + fun `parser preserves repeated tracks in their original order`() { + val songs = listOf( + song("intro", "/Music/intro.flac"), + song("chorus", "/Music/chorus.flac"), + song("outro", "/Music/outro.flac"), + ) + val content = """ + /Music/intro.flac + /Music/chorus.flac + /Music/chorus.flac + /Music/outro.flac + """.trimIndent() + + val result = M3uManager.parseContent(content, songs) + + assertThat(result.songIds) + .containsExactly("intro", "chorus", "chorus", "outro") + .inOrder() + assertThat(result.unresolvedEntries).isEmpty() + assertThat(result.ambiguousEntries).isEmpty() + } + + @Test + fun `bounded reader accepts content exactly at the limit`() { + val content = readM3uTextBounded(StringReader("1234"), maxCharacters = 4) + + assertThat(content).isEqualTo("1234") + } + + @Test + fun `bounded reader rejects content beyond the limit`() { + assertThrows(IOException::class.java) { + readM3uTextBounded(StringReader("12345"), maxCharacters = 4) + } + } + + private fun song(id: String, path: String, contentUri: String = "content://media/$id") = Song( + id = id, + title = "Song $id", + artist = "Artist", + artistId = 1L, + album = "Album", + albumId = 1L, + path = path, + contentUriString = contentUri, + albumArtUriString = null, + duration = 60_000L, + mimeType = "audio/flac", + bitrate = null, + sampleRate = null, + ) +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncPlannerTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncPlannerTest.kt new file mode 100644 index 00000000..1dfd6ad4 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncPlannerTest.kt @@ -0,0 +1,61 @@ +package com.lostf1sh.pixelplayeross.data.playlist + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class M3uSyncPlannerTest { + + @Test + fun `new app playlist exports and new file imports`() { + assertThat( + M3uSyncPlanner.decide( + appHash = "app", + fileHash = null, + checkpoint = null, + ) + ).isEqualTo(M3uSyncAction.EXPORT) + assertThat( + M3uSyncPlanner.decide( + appHash = null, + fileHash = "file", + checkpoint = null, + ) + ).isEqualTo(M3uSyncAction.IMPORT) + } + + @Test + fun `only changed side wins after checkpoint`() { + val checkpoint = M3uSyncCheckpoint(appHash = "old", fileHash = "old") + + assertThat(M3uSyncPlanner.decide("new", "old", checkpoint)) + .isEqualTo(M3uSyncAction.EXPORT) + assertThat(M3uSyncPlanner.decide("old", "new", checkpoint)) + .isEqualTo(M3uSyncAction.IMPORT) + assertThat(M3uSyncPlanner.decide("old", "old", checkpoint)) + .isEqualTo(M3uSyncAction.NONE) + } + + @Test + fun `simultaneous changes never overwrite either side`() { + val checkpoint = M3uSyncCheckpoint(appHash = "old-app", fileHash = "old-file") + + assertThat(M3uSyncPlanner.decide("new-app", "new-file", checkpoint)) + .isEqualTo(M3uSyncAction.CONFLICT) + } + + @Test + fun `matching simultaneous changes converge without a false conflict`() { + val checkpoint = M3uSyncCheckpoint(appHash = "old-app", fileHash = "old-file") + + assertThat(M3uSyncPlanner.decide("same-new-state", "same-new-state", checkpoint)) + .isEqualTo(M3uSyncAction.NONE) + } + + @Test + fun `first link with different existing content is a conflict`() { + assertThat(M3uSyncPlanner.decide("app", "file", checkpoint = null)) + .isEqualTo(M3uSyncAction.CONFLICT) + assertThat(M3uSyncPlanner.decide("same", "same", checkpoint = null)) + .isEqualTo(M3uSyncAction.NONE) + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncSafetyTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncSafetyTest.kt new file mode 100644 index 00000000..38a633a9 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncSafetyTest.kt @@ -0,0 +1,67 @@ +package com.lostf1sh.pixelplayeross.data.playlist + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class M3uSyncSafetyTest { + + @Test + fun `external marker never reuses an occupied playlist id`() { + val selectedId = M3uSyncSafety.safeRequestedPlaylistId( + markerId = "navidrome_playlist_remote-id", + occupiedPlaylistIds = setOf("navidrome_playlist_remote-id"), + ) + + assertThat(selectedId).isNull() + } + + @Test + fun `external marker can preserve an unclaimed playlist id`() { + val selectedId = M3uSyncSafety.safeRequestedPlaylistId( + markerId = "pixel-player-playlist-id", + occupiedPlaylistIds = setOf("another-playlist"), + ) + + assertThat(selectedId).isEqualTo("pixel-player-playlist-id") + } + + @Test + fun `first import requires every file entry to resolve unambiguously`() { + val complete = M3uParseResult( + playlistId = null, + songIds = listOf("song-1"), + unresolvedEntries = emptyList(), + ambiguousEntries = emptyList(), + ) + val unresolved = complete.copy(unresolvedEntries = listOf("missing.ogg")) + val ambiguous = complete.copy(ambiguousEntries = listOf("shared.ogg")) + + assertThat(M3uSyncSafety.canImportEntireFile(complete)).isTrue() + assertThat(M3uSyncSafety.canImportEntireFile(unresolved)).isFalse() + assertThat(M3uSyncSafety.canImportEntireFile(ambiguous)).isFalse() + } + + @Test + fun `export reports every playlist song missing from the library`() { + val missing = M3uSyncSafety.missingPlaylistSongIds( + playlistSongIds = listOf("present", "missing", "missing-again", "missing"), + availableSongIds = setOf("present"), + ) + + assertThat(missing).containsExactly("missing", "missing-again").inOrder() + } + + @Test + fun `replacement names keep incomplete writes out of scans and backups recoverable`() { + val names = m3uReplacementNames( + desiredName = "Road Trip.m3u8", + transactionId = "transaction", + ) + + assertThat(names.temporary).endsWith(".tmp") + assertThat(names.temporary.lowercase().endsWith(".m3u")).isFalse() + assertThat(names.temporary.lowercase().endsWith(".m3u8")).isFalse() + assertThat(names.backup).endsWith(".m3u8") + assertThat(names.temporary).isNotEqualTo(names.backup) + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/M3uSyncPreferencesTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/M3uSyncPreferencesTest.kt new file mode 100644 index 00000000..642e94e8 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/preferences/M3uSyncPreferencesTest.kt @@ -0,0 +1,61 @@ +package com.lostf1sh.pixelplayeross.data.preferences + +import com.google.common.truth.Truth.assertThat +import com.lostf1sh.pixelplayeross.data.playlist.M3uSyncCheckpoint +import org.junit.jupiter.api.Test + +class M3uSyncPreferencesTest { + + @Test + fun `link replacement is rejected after the selected tree changes`() { + val oldLink = link("playlist", "content://old/document") + val newLink = link("playlist", "content://new/document") + val config = M3uSyncConfig( + treeUri = "content://tree/new", + revision = 2, + links = mapOf(oldLink.playlistId to oldLink), + ) + + val updated = config.withLinksIfSelectionMatches( + expectedTreeUri = "content://tree/old", + expectedRevision = 1, + replacementLinks = mapOf(newLink.playlistId to newLink), + ) + + assertThat(updated).isNull() + } + + @Test + fun `link replacement commits for the same selected tree`() { + val newLink = link("playlist", "content://tree/document") + val config = M3uSyncConfig(treeUri = "content://tree", revision = 7) + + val updated = config.withLinksIfSelectionMatches( + expectedTreeUri = "content://tree", + expectedRevision = 7, + replacementLinks = mapOf(newLink.playlistId to newLink), + ) + + assertThat(updated?.links).containsExactly(newLink.playlistId, newLink) + } + + @Test + fun `link replacement is rejected when the same tree was reselected`() { + val config = M3uSyncConfig(treeUri = "content://tree", revision = 8) + + val updated = config.withLinksIfSelectionMatches( + expectedTreeUri = "content://tree", + expectedRevision = 7, + replacementLinks = mapOf("playlist" to link("playlist", "content://tree/document")), + ) + + assertThat(updated).isNull() + } + + private fun link(playlistId: String, documentUri: String) = M3uSyncLink( + playlistId = playlistId, + documentUri = documentUri, + fileName = "$playlistId.m3u8", + checkpoint = M3uSyncCheckpoint("app", "file"), + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb8ef824..9890e992 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,7 @@ jdom2 = "2.0.6.1" jose4j = "0.9.6" httpclient = "4.5.14" orgJson = "20260522" +snakeYaml = "2.4" # DI dagger = "2.60.1" @@ -119,6 +120,7 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "roomRuntime" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomRuntime" } androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } @@ -179,6 +181,7 @@ mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "moc turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } org-json = { group = "org.json", name = "json", version.ref = "orgJson" } +snakeyaml = { group = "org.yaml", name = "snakeyaml", version.ref = "snakeYaml" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } From 66e4bc476fa59fd5489ed905e65da5b29e321db1 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:10:15 +0300 Subject: [PATCH 11/18] Edit ratings and custom audio metadata (#111) The song editor can read, validate, add, remove, and write optional rating and text metadata across the supported tag families without discarding unknown fields. --- .../data/media/CustomMetadata.kt | 273 ++++++++++++++++++ .../data/media/SongMetadataEditor.kt | 188 +++++++++++- .../components/DailyMixSection.kt | 5 +- .../presentation/components/EditSongSheet.kt | 221 ++++++++++++-- .../components/SongInfoBottomSheet.kt | 9 +- .../presentation/screens/AlbumDetailScreen.kt | 9 +- .../screens/ArtistDetailScreen.kt | 5 +- .../presentation/screens/DailyMixScreen.kt | 5 +- .../presentation/screens/GenreDetailScreen.kt | 5 +- .../presentation/screens/LibraryScreen.kt | 5 +- .../screens/PlaylistDetailScreen.kt | 5 +- .../screens/RecentlyPlayedScreen.kt | 5 +- .../presentation/screens/SearchScreen.kt | 5 +- .../viewmodel/MetadataEditStateHolder.kt | 5 +- .../presentation/viewmodel/PlayerViewModel.kt | 18 +- .../main/res/values-tr/strings_components.xml | 10 + .../main/res/values/strings_components.xml | 12 +- .../data/media/CustomMetadataTest.kt | 136 +++++++++ 18 files changed, 868 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadata.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadataTest.kt diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadata.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadata.kt new file mode 100644 index 00000000..c4ff73b6 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadata.kt @@ -0,0 +1,273 @@ +package com.lostf1sh.pixelplayeross.data.media + +import java.util.Locale +import kotlin.math.roundToInt + +/** A text metadata field that can be shown in the single-song editor. */ +data class CustomMetadataField( + val key: String, + val value: String +) + +/** A null [value] removes the field from the file. */ +data class CustomMetadataFieldUpdate( + val key: String, + val value: String? +) + +/** + * Separates an intentional clear from leaving an unread or untouched tag alone. + * This distinction matters when Android cannot read the original file before requesting write access. + */ +sealed interface MetadataValueUpdate { + data object Keep : MetadataValueUpdate + data object Clear : MetadataValueUpdate + data class Set(val value: T) : MetadataValueUpdate +} + +data class CustomMetadataChanges( + val rating: MetadataValueUpdate = MetadataValueUpdate.Keep, + val fields: List = emptyList() +) { + val hasChanges: Boolean + get() = rating !is MetadataValueUpdate.Keep || fields.isNotEmpty() +} + +enum class MetadataTagFamily { + ID3, + VORBIS, + MP4, + UNSUPPORTED +} + +private const val MAX_CUSTOM_FIELD_COUNT = 24 +private const val MAX_CUSTOM_FIELD_KEY_LENGTH = 64 +private const val MAX_CUSTOM_FIELD_VALUE_LENGTH = 4_096 + +/** Fields already owned by the fixed editor, artwork pipeline, or ReplayGain editor. */ +private val RESERVED_CUSTOM_METADATA_KEYS = setOf( + "TITLE", + "ARTIST", + "ALBUM", + "ALBUMARTIST", + "ALBUM ARTIST", + "BAND", + "COMPOSER", + "GENRE", + "LYRICS", + "UNSYNCEDLYRICS", + "TRACK", + "TRACKNUMBER", + "DISC", + "DISCNUMBER", + "DISC_NO", + "TRACK_NO", + "SINGLE_DISC_TRACK_NO", + "RATING", + "POPULARIMETER", + "METADATA_BLOCK_PICTURE", + "COVERART", + "COVERARTMIME", + "PICTURE", + "REPLAYGAIN_TRACK_GAIN", + "REPLAYGAIN_TRACK_GAIN_DB", + "REPLAYGAIN_ALBUM_GAIN", + "REPLAYGAIN_ALBUM_GAIN_DB", + "R128_TRACK_GAIN", + "R128_ALBUM_GAIN" +) + +private val RESERVED_CUSTOM_METADATA_COMPACT_KEYS = + RESERVED_CUSTOM_METADATA_KEYS.mapTo(mutableSetOf()) { key -> + key.filter { it in 'A'..'Z' || it in '0'..'9' } + } + +private val HIDDEN_TECHNICAL_METADATA_PREFIXES = listOf( + "ACOUSTID_", + "MUSICBRAINZ_" +) + +private val HIDDEN_TECHNICAL_METADATA_KEYS = setOf( + "ENCODER", + "ENCODED_BY", + "LENGTH" +) + +internal fun metadataTagFamily(extension: String): MetadataTagFamily = when ( + extension.trim().removePrefix(".").lowercase(Locale.ROOT) +) { + "mp3", "wav", "aif", "aiff" -> MetadataTagFamily.ID3 + "flac", "ogg", "oga", "opus" -> MetadataTagFamily.VORBIS + "m4a", "m4b", "mp4" -> MetadataTagFamily.MP4 + else -> MetadataTagFamily.UNSUPPORTED +} + +/** + * Encodes a user-facing 0–5 rating using the convention native to the container. + * ID3 POPM stores a byte, MP4 score uses 0–100, and Vorbis comments use the star value. + */ +internal fun encodeRatingForTag(rating: Int, family: MetadataTagFamily): String { + require(rating in 0..5) { "Rating must be between 0 and 5" } + return when (family) { + MetadataTagFamily.ID3 -> when (rating) { + 0 -> "0" + 1 -> "1" + 2 -> "64" + 3 -> "128" + 4 -> "196" + else -> "255" + } + MetadataTagFamily.MP4 -> (rating * 20).toString() + MetadataTagFamily.VORBIS -> rating.toString() + MetadataTagFamily.UNSUPPORTED -> error("This file format does not support custom metadata") + } +} + +internal fun decodeRatingFromTag(rawValue: String?, family: MetadataTagFamily): Int? { + val numericValue = rawValue?.trim()?.toDoubleOrNull() ?: return null + if (!numericValue.isFinite() || numericValue < 0) return null + + // Some taggers write literal stars even in ID3/MP4. Accept those values before scaling. + if (numericValue <= 5.0) return numericValue.roundToInt().coerceIn(0, 5) + + return when (family) { + MetadataTagFamily.ID3 -> when { + numericValue < 32 -> 1 + numericValue < 96 -> 2 + numericValue < 162 -> 3 + numericValue < 226 -> 4 + else -> 5 + } + MetadataTagFamily.MP4, + MetadataTagFamily.VORBIS -> (numericValue / 20.0).roundToInt().coerceIn(0, 5) + MetadataTagFamily.UNSUPPORTED -> null + } +} + +internal fun validateCustomMetadataChanges( + changes: CustomMetadataChanges +): Result = runCatching { + val rating = changes.rating + if (rating is MetadataValueUpdate.Set && rating.value !in 0..5) { + throw IllegalArgumentException("Rating must be between 0 and 5") + } + if (changes.fields.size > MAX_CUSTOM_FIELD_COUNT) { + throw IllegalArgumentException("A maximum of $MAX_CUSTOM_FIELD_COUNT custom fields is supported") + } + + val seenKeys = mutableSetOf() + val normalizedFields = changes.fields.map { field -> + val normalizedKey = normalizeCustomMetadataKey(field.key) + if (isReservedCustomMetadataKey(normalizedKey)) { + throw IllegalArgumentException("$normalizedKey is managed by the standard metadata editor") + } + if (!seenKeys.add(normalizedKey)) { + throw IllegalArgumentException("Duplicate custom metadata field: $normalizedKey") + } + + val normalizedValue = field.value?.trim() + if (normalizedValue != null) { + if (normalizedValue.isEmpty()) { + throw IllegalArgumentException("Custom metadata values cannot be empty") + } + if (normalizedValue.length > MAX_CUSTOM_FIELD_VALUE_LENGTH) { + throw IllegalArgumentException("Custom metadata value is too long") + } + } + CustomMetadataFieldUpdate(normalizedKey, normalizedValue) + } + + changes.copy(fields = normalizedFields) +} + +private fun normalizeCustomMetadataKey(rawKey: String): String { + val normalized = rawKey.trim().uppercase(Locale.ROOT) + if (normalized.isEmpty()) { + throw IllegalArgumentException("Custom metadata field name cannot be empty") + } + if (normalized.length > MAX_CUSTOM_FIELD_KEY_LENGTH) { + throw IllegalArgumentException("Custom metadata field name is too long") + } + if (normalized.any { character -> + character !in 'A'..'Z' && + character !in '0'..'9' && + character != ' ' && + character != '_' && + character != '-' && + character != '.' + } + ) { + throw IllegalArgumentException("Custom metadata field names may use A-Z, 0-9, spaces, dots, dashes, and underscores") + } + return normalized +} + +private fun isReservedCustomMetadataKey(key: String): Boolean { + if (key in RESERVED_CUSTOM_METADATA_KEYS) return true + val compactKey = key.filter { it in 'A'..'Z' || it in '0'..'9' } + return compactKey in RESERVED_CUSTOM_METADATA_COMPACT_KEYS +} + +internal fun buildCustomMetadataChanges( + metadataWasRead: Boolean, + originalRating: Int?, + editedRating: Int?, + originalFields: List, + editedFields: List +): Result = runCatching { + val ratingUpdate = when { + !metadataWasRead && editedRating == null -> MetadataValueUpdate.Keep + editedRating == originalRating -> MetadataValueUpdate.Keep + editedRating == null -> MetadataValueUpdate.Clear + else -> MetadataValueUpdate.Set(editedRating) + } + + val normalizedOriginal = originalFields.associate { field -> + normalizeCustomMetadataKey(field.key) to field.value.trim() + } + val normalizedEdited = linkedMapOf() + editedFields.forEach { field -> + val key = normalizeCustomMetadataKey(field.key) + if (normalizedEdited.containsKey(key)) { + throw IllegalArgumentException("Duplicate custom metadata field: $key") + } + normalizedEdited[key] = field.value.trim() + } + + val updates = buildList { + normalizedEdited.forEach { (key, value) -> + if (normalizedOriginal[key] != value) { + add(CustomMetadataFieldUpdate(key, value)) + } + } + normalizedOriginal.keys + .filterNot(normalizedEdited::containsKey) + .forEach { removedKey -> add(CustomMetadataFieldUpdate(removedKey, null)) } + } + + validateCustomMetadataChanges( + CustomMetadataChanges(rating = ratingUpdate, fields = updates) + ).getOrThrow() +} + +internal fun extractEditableCustomMetadataFields( + propertyMap: Map> +): List = propertyMap.mapNotNull { (rawKey, values) -> + val portableKey = if (rawKey.startsWith("----:com.apple.iTunes:", ignoreCase = true)) { + rawKey.substringAfterLast(':') + } else { + rawKey + } + val key = runCatching { normalizeCustomMetadataKey(portableKey) }.getOrNull() + ?: return@mapNotNull null + if (isReservedCustomMetadataKey(key) || + key in HIDDEN_TECHNICAL_METADATA_KEYS || + HIDDEN_TECHNICAL_METADATA_PREFIXES.any(key::startsWith) || + values.size != 1 + ) { + return@mapNotNull null + } + val value = values.single().trim() + if (value.isEmpty() || value.length > MAX_CUSTOM_FIELD_VALUE_LENGTH) return@mapNotNull null + CustomMetadataField(key, value) +}.sortedBy(CustomMetadataField::key) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt index 736bc9d9..17bc5dd1 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt @@ -30,6 +30,7 @@ import org.gagravarr.opus.OpusTags import org.jaudiotagger.audio.AudioFileIO import org.jaudiotagger.tag.FieldKey import org.jaudiotagger.tag.Tag +import org.jaudiotagger.tag.aiff.AiffTag import org.jaudiotagger.tag.flac.FlacTag import org.jaudiotagger.tag.id3.AbstractID3v2Frame import org.jaudiotagger.tag.id3.AbstractID3v2Tag @@ -39,6 +40,7 @@ import org.jaudiotagger.tag.id3.ID3v23Tag import org.jaudiotagger.tag.id3.ID3v24Frame import org.jaudiotagger.tag.id3.ID3v24Frames import org.jaudiotagger.tag.id3.ID3v24Tag +import org.jaudiotagger.tag.id3.Id3SupportingTag import org.jaudiotagger.tag.id3.framebody.FrameBodyTXXX import org.jaudiotagger.tag.images.AndroidArtwork import org.jaudiotagger.tag.mp4.Mp4Tag @@ -240,6 +242,7 @@ class SongMetadataEditor( newDiscNumber: Int?, newReplayGainTrackGainDb: String? = null, newReplayGainAlbumGainDb: String? = null, + customMetadataChanges: CustomMetadataChanges = CustomMetadataChanges(), coverArtUpdate: CoverArtUpdate? = null, ): SongMetadataEditResult = withContext(Dispatchers.IO) { val validationError = validateMetadataInput(newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics) @@ -279,6 +282,15 @@ class SongMetadataEditor( errorMessage = error.message ?: "Invalid Album ReplayGain value" ) } + val validatedCustomMetadataChanges = validateCustomMetadataChanges(customMetadataChanges) + .getOrElse { error -> + return@withContext SongMetadataEditResult( + success = false, + updatedAlbumArtUri = null, + error = MetadataEditError.INVALID_INPUT, + errorMessage = error.message ?: "Invalid custom metadata" + ) + } val filePath = getFilePathFromMediaStore(songId) @@ -322,6 +334,15 @@ class SongMetadataEditor( } val needsExtensionSwap = effectiveExtension != extension && detectedContainer != DetectedContainer.OGG_OPUS + val tagFamily = metadataTagFamily(effectiveExtension) + if (validatedCustomMetadataChanges.hasChanges && tagFamily == MetadataTagFamily.UNSUPPORTED) { + return@withContext SongMetadataEditResult( + success = false, + updatedAlbumArtUri = null, + error = MetadataEditError.UNSUPPORTED_FORMAT, + errorMessage = "Rating and custom fields are supported for MP3, WAV, AIFF, FLAC, OGG, Opus, M4A, M4B, and MP4 files" + ) + } val flacAnalysis = isProblematicFlacFile(finalFilePath) val isHighResFlac = flacAnalysis is FlacAnalysisResult.Problematic val useVorbisJavaPrimary = effectiveExtension == "opus" @@ -344,6 +365,7 @@ class SongMetadataEditor( newDiscNumber = newDiscNumber, replayGainTrackUpdate = replayGainTrackUpdate, replayGainAlbumUpdate = replayGainAlbumUpdate, + customMetadataChanges = validatedCustomMetadataChanges, coverArtUpdate = coverArtUpdate ) } else if (useJAudioTaggerPrimary) { @@ -361,6 +383,8 @@ class SongMetadataEditor( newDiscNumber = newDiscNumber, replayGainTrackUpdate = replayGainTrackUpdate, replayGainAlbumUpdate = replayGainAlbumUpdate, + customMetadataChanges = validatedCustomMetadataChanges, + tagFamily = tagFamily, coverArtUpdate = coverArtUpdate ) } else { @@ -396,16 +420,33 @@ class SongMetadataEditor( newDiscNumber = newDiscNumber, replayGainTrackUpdate = replayGainTrackUpdate, replayGainAlbumUpdate = replayGainAlbumUpdate, + customMetadataChanges = validatedCustomMetadataChanges, + tagFamily = tagFamily, coverArtUpdate = coverArtUpdate ) - } else true + } else if (validatedCustomMetadataChanges.hasChanges) { + updateCustomMetadataWithJAudioTagger( + filePath = path, + customMetadataChanges = validatedCustomMetadataChanges, + tagFamily = tagFamily + ) + } else { + true + } } } + val needsTransactionalTempWrite = needsExtensionSwap || + (validatedCustomMetadataChanges.hasChanges && + !useVorbisJavaPrimary && + !useJAudioTaggerPrimary) val fileUpdateSuccess = if (!fileExists) { Timber.tag(TAG).e("METADATA_EDIT: File does not exist: $finalFilePath") false - } else if (needsExtensionSwap) { + } else if (needsTransactionalTempWrite) { + // TagLib and the container-specific rating/custom-field writer are two separate + // commits. Run both against a copy so a failure in the second pass cannot leave + // only the standard fields changed in the user's original file. writeMetadataViaExtensionSwap(finalFilePath, effectiveExtension, runPipeline) } else { runPipeline(finalFilePath) @@ -865,6 +906,8 @@ class SongMetadataEditor( newDiscNumber: Int?, replayGainTrackUpdate: ReplayGainUpdate = ReplayGainUpdate.Keep, replayGainAlbumUpdate: ReplayGainUpdate = ReplayGainUpdate.Keep, + customMetadataChanges: CustomMetadataChanges = CustomMetadataChanges(), + tagFamily: MetadataTagFamily = metadataTagFamily(filePath.substringAfterLast('.', "")), coverArtUpdate: CoverArtUpdate? = null ): Boolean { val targetFile = File(filePath) @@ -907,6 +950,7 @@ class SongMetadataEditor( } tag.applyReplayGainUpdate(REPLAYGAIN_TRACK_GAIN_KEY, replayGainTrackUpdate) tag.applyReplayGainUpdate(REPLAYGAIN_ALBUM_GAIN_KEY, replayGainAlbumUpdate) + tag.applyCustomMetadataChanges(customMetadataChanges, tagFamily) coverArtUpdate?.let { update -> if (update.isDeletion) { @@ -946,6 +990,32 @@ class SongMetadataEditor( } } + /** + * TagLib remains the established writer for ordinary MP3/MP4/FLAC edits. Custom fields are + * committed in a second, narrowly scoped JAudioTagger pass so Rating is represented by the + * container's real POPM/score/comment field instead of a generic TagLib property. + */ + private fun updateCustomMetadataWithJAudioTagger( + filePath: String, + customMetadataChanges: CustomMetadataChanges, + tagFamily: MetadataTagFamily + ): Boolean { + if (!customMetadataChanges.hasChanges) return true + + return try { + java.util.logging.Logger.getLogger("org.jaudiotagger").level = java.util.logging.Level.OFF + val audioFile = AudioFileIO.read(File(filePath)) + val tag = audioFile.tag ?: audioFile.createDefaultTag() + tag.applyCustomMetadataChanges(customMetadataChanges, tagFamily) + audioFile.commit() + Timber.tag(TAG).d("JAUDIOTAGGER: Updated rating/custom metadata: $filePath") + true + } catch (error: Exception) { + Timber.tag(TAG).e(error, "JAUDIOTAGGER: Failed to update rating/custom metadata: $filePath") + false + } + } + private fun updateFileMetadataWithVorbisJava( filePath: String, newTitle: String, @@ -959,6 +1029,7 @@ class SongMetadataEditor( newDiscNumber: Int?, replayGainTrackUpdate: ReplayGainUpdate = ReplayGainUpdate.Keep, replayGainAlbumUpdate: ReplayGainUpdate = ReplayGainUpdate.Keep, + customMetadataChanges: CustomMetadataChanges = CustomMetadataChanges(), coverArtUpdate: CoverArtUpdate? = null ): Boolean { val audioFile = File(filePath) @@ -991,6 +1062,7 @@ class SongMetadataEditor( tags.replaceSingleComment("DISCNUMBER", newDiscNumber?.takeIf { it > 0 }?.toString()) tags.applyReplayGainUpdate(REPLAYGAIN_TRACK_GAIN_KEY, replayGainTrackUpdate) tags.applyReplayGainUpdate(REPLAYGAIN_ALBUM_GAIN_KEY, replayGainAlbumUpdate) + tags.applyCustomMetadataChanges(customMetadataChanges) coverArtUpdate?.let { update -> tags.applyCoverArtUpdate(update) } @@ -1145,6 +1217,20 @@ private fun OpusTags.applyReplayGainUpdate(key: String, update: ReplayGainUpdate } } +private fun OpusTags.applyCustomMetadataChanges(changes: CustomMetadataChanges) { + when (val ratingUpdate = changes.rating) { + MetadataValueUpdate.Keep -> Unit + MetadataValueUpdate.Clear -> removeComments("RATING") + is MetadataValueUpdate.Set -> replaceSingleComment( + "RATING", + encodeRatingForTag(ratingUpdate.value, MetadataTagFamily.VORBIS) + ) + } + changes.fields.forEach { field -> + replaceSingleComment(field.key, field.value) + } +} + private fun OpusTags.applyCoverArtUpdate(update: CoverArtUpdate) { removeComments("METADATA_BLOCK_PICTURE") removeComments("COVERART") @@ -1223,13 +1309,90 @@ private fun Tag.applyReplayGainUpdate(key: String, update: ReplayGainUpdate) { } } -private fun Tag.upsertReplayGainField(key: String, value: String) { +private fun Tag.applyCustomMetadataChanges( + changes: CustomMetadataChanges, + family: MetadataTagFamily +) { + when (val ratingUpdate = changes.rating) { + MetadataValueUpdate.Keep -> Unit + MetadataValueUpdate.Clear -> { + runCatching { deleteField(FieldKey.RATING) } + // Older PixelPlayer builds and third-party taggers may have used a generic RATING + // field. Remove it as well even when deleting the standard field was a successful no-op. + runCatching { removeRawCustomMetadataField("RATING") } + } + is MetadataValueUpdate.Set -> { + val encodedRating = encodeRatingForTag(ratingUpdate.value, family) + runCatching { deleteField(FieldKey.RATING) } + runCatching { removeRawCustomMetadataField("RATING") } + runCatching { setField(FieldKey.RATING, encodedRating) } + .getOrElse { + upsertRawCustomMetadataField("RATING", encodedRating) + } + } + } + + changes.fields.forEach { field -> + applyCustomMetadataField(field) + } +} + +private fun Tag.applyCustomMetadataField(update: CustomMetadataFieldUpdate) { + val standardFieldKey = runCatching { + FieldKey.valueOf(update.key.replace(Regex("[ .-]+"), "_")) + }.getOrNull() + + if (standardFieldKey != null) { + runCatching { deleteField(standardFieldKey) } + runCatching { removeRawCustomMetadataField(update.key) } + if (update.value == null) { + return + } + val standardUpdate = runCatching { setField(standardFieldKey, update.value) } + if (standardUpdate.isSuccess) return + } + + if (update.value == null) { + removeRawCustomMetadataField(update.key) + } else { + upsertRawCustomMetadataField(update.key, update.value) + } +} + +private fun Tag.upsertRawCustomMetadataField(key: String, value: String) { when (this) { is AbstractID3v2Tag -> upsertReplayGainId3Field(key, value) - is WavTag -> { - val id3Tag = getID3Tag() ?: ID3v24Tag().also(::setID3Tag) - id3Tag.upsertReplayGainId3Field(key, value) + is Id3SupportingTag -> getOrCreateEmbeddedId3Tag().upsertReplayGainId3Field(key, value) + is FlacTag -> setField(key, value) + is VorbisCommentTag -> setField(key, value) + is Mp4Tag -> { + val fieldId = replayGainMp4FieldId(key) + deleteField(fieldId) + setField(Mp4TagReverseDnsField(fieldId, MP4_REVERSE_DNS_ISSUER, key, value)) } + else -> throw IllegalArgumentException( + "Custom metadata is not supported for tag type ${this::class.java.simpleName}" + ) + } +} + +private fun Tag.removeRawCustomMetadataField(key: String) { + when (this) { + is AbstractID3v2Tag -> removeReplayGainId3Field(key) + is Id3SupportingTag -> getID3Tag()?.removeReplayGainId3Field(key) + is FlacTag -> deleteField(key) + is VorbisCommentTag -> deleteField(key) + is Mp4Tag -> deleteField(replayGainMp4FieldId(key)) + else -> throw IllegalArgumentException( + "Custom metadata is not supported for tag type ${this::class.java.simpleName}" + ) + } +} + +private fun Tag.upsertReplayGainField(key: String, value: String) { + when (this) { + is AbstractID3v2Tag -> upsertReplayGainId3Field(key, value) + is Id3SupportingTag -> getOrCreateEmbeddedId3Tag().upsertReplayGainId3Field(key, value) is FlacTag -> setField(key, value) is VorbisCommentTag -> setField(key, value) is Mp4Tag -> { @@ -1244,7 +1407,7 @@ private fun Tag.upsertReplayGainField(key: String, value: String) { private fun Tag.removeReplayGainField(key: String) { when (this) { is AbstractID3v2Tag -> removeReplayGainId3Field(key) - is WavTag -> getID3Tag()?.removeReplayGainId3Field(key) + is Id3SupportingTag -> getID3Tag()?.removeReplayGainId3Field(key) is FlacTag -> deleteField(key) is VorbisCommentTag -> deleteField(key) is Mp4Tag -> deleteField(replayGainMp4FieldId(key)) @@ -1252,6 +1415,17 @@ private fun Tag.removeReplayGainField(key: String) { } } +private fun Id3SupportingTag.getOrCreateEmbeddedId3Tag(): AbstractID3v2Tag { + getID3Tag()?.let { return it } + val created = when (this) { + is AiffTag -> AiffTag.createDefaultID3Tag() + is WavTag -> WavTag.createDefaultID3Tag() + else -> ID3v24Tag() + } + setID3Tag(created) + return created +} + private fun AbstractID3v2Tag.upsertReplayGainId3Field(key: String, value: String) { val frame = if (this is ID3v23Tag) { ID3v23Frame(ID3v23Frames.FRAME_ID_V3_USER_DEFINED_INFO) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/DailyMixSection.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/DailyMixSection.kt index fdbbb524..5ef741dd 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/DailyMixSection.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/DailyMixSection.kt @@ -145,7 +145,7 @@ fun DailyMixSection( onNavigateToGenre(song) showSongInfoSheet = false }, - onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate, customMetadataChanges -> playerViewModel.editSongMetadata( song, newTitle, @@ -159,7 +159,8 @@ fun DailyMixSection( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, removeFromListTrigger = {} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/EditSongSheet.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/EditSongSheet.kt index 263d59e1..fa3afc69 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/EditSongSheet.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/EditSongSheet.kt @@ -6,6 +6,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.layout.* @@ -52,6 +53,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.automirrored.rounded.Notes import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Repeat import androidx.compose.material.icons.rounded.RepeatOne import androidx.compose.ui.platform.LocalContext @@ -84,6 +86,9 @@ import androidx.compose.ui.window.DialogProperties import androidx.media3.common.Player import com.lostf1sh.pixelplayeross.data.media.AudioMetadataReader import com.lostf1sh.pixelplayeross.data.media.CoverArtUpdate +import com.lostf1sh.pixelplayeross.data.media.CustomMetadataChanges +import com.lostf1sh.pixelplayeross.data.media.CustomMetadataField +import com.lostf1sh.pixelplayeross.data.media.buildCustomMetadataChanges import dev.shreyaspatil.capturable.controller.rememberCaptureController import java.io.ByteArrayOutputStream import java.util.Locale @@ -112,7 +117,8 @@ fun EditSongSheet( discNumber: Int?, replayGainTrackGainDb: String, replayGainAlbumGainDb: String, - coverArtUpdate: CoverArtUpdate? + coverArtUpdate: CoverArtUpdate?, + customMetadataChanges: CustomMetadataChanges ) -> Unit ) { val transitionState = remember { MutableTransitionState(false) } @@ -158,7 +164,8 @@ private fun EditSongContent( discNumber: Int?, replayGainTrackGainDb: String, replayGainAlbumGainDb: String, - coverArtUpdate: CoverArtUpdate? + coverArtUpdate: CoverArtUpdate?, + customMetadataChanges: CustomMetadataChanges ) -> Unit, ) { var title by remember { mutableStateOf(song.title) } @@ -172,6 +179,12 @@ private fun EditSongContent( var discNumber by remember { mutableStateOf(song.discNumber?.toString() ?: "") } var replayGainTrackGainDb by remember { mutableStateOf("") } var replayGainAlbumGainDb by remember { mutableStateOf("") } + var metadataWasRead by remember { mutableStateOf(false) } + var originalRating by remember { mutableStateOf(null) } + var rating by remember { mutableStateOf(null) } + var originalCustomFields by remember { mutableStateOf>(emptyList()) } + var customFields by remember { mutableStateOf>(emptyList()) } + var customMetadataError by remember { mutableStateOf(null) } var coverArtPreview by remember { mutableStateOf(null) } var editedCoverArt by remember { mutableStateOf(null) } var isCoverArtDeleted by remember { mutableStateOf(false) } @@ -199,6 +212,12 @@ private fun EditSongContent( discNumber = song.discNumber?.toString() ?: "" replayGainTrackGainDb = "" replayGainAlbumGainDb = "" + metadataWasRead = false + originalRating = null + rating = null + originalCustomFields = emptyList() + customFields = emptyList() + customMetadataError = null coverArtPreview = null editedCoverArt = null isCoverArtDeleted = false @@ -208,7 +227,11 @@ private fun EditSongContent( try { val file = java.io.File(song.path) if (file.exists()) { - AudioMetadataReader.read(file, readArtwork = false) + AudioMetadataReader.read( + file, + readArtwork = false, + readCustomMetadata = true + ) } else { null } @@ -227,6 +250,11 @@ private fun EditSongContent( embeddedMetadata?.composer?.takeIf { it.isNotBlank() }?.let { composer = it } replayGainTrackGainDb = formatReplayGainForInput(embeddedMetadata?.replayGainTrackGainDb) replayGainAlbumGainDb = formatReplayGainForInput(embeddedMetadata?.replayGainAlbumGainDb) + metadataWasRead = embeddedMetadata != null + originalRating = embeddedMetadata?.rating + rating = embeddedMetadata?.rating + originalCustomFields = embeddedMetadata?.customFields.orEmpty() + customFields = embeddedMetadata?.customFields.orEmpty() } } @@ -375,6 +403,152 @@ private fun EditSongContent( } } + item { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.edit_song_custom_metadata_heading), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.edit_song_custom_metadata_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = stringResource(R.string.edit_song_rating), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = rating == null, + onClick = { + rating = null + customMetadataError = null + }, + label = { Text(stringResource(R.string.edit_song_rating_not_set)) } + ) + (0..5).forEach { value -> + FilterChip( + selected = rating == value, + onClick = { + rating = value + customMetadataError = null + }, + label = { Text(value.toString()) } + ) + } + } + + HorizontalDivider() + + customFields.forEachIndexed { index, field -> + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = field.key, + onValueChange = { updatedKey -> + customFields = customFields.toMutableList().also { fields -> + fields[index] = field.copy(key = updatedKey) + } + customMetadataError = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.edit_song_custom_field_name)) }, + singleLine = true, + shape = textFieldShape, + colors = textFieldColors + ) + OutlinedTextField( + value = field.value, + onValueChange = { updatedValue -> + customFields = customFields.toMutableList().also { fields -> + fields[index] = field.copy(value = updatedValue) + } + customMetadataError = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.edit_song_custom_field_value)) }, + singleLine = true, + shape = textFieldShape, + colors = textFieldColors + ) + TextButton( + onClick = { + customFields = customFields.toMutableList().also { fields -> + fields.removeAt(index) + } + customMetadataError = null + }, + modifier = Modifier.align(Alignment.End) + ) { + Icon( + Icons.Rounded.Delete, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.edit_song_remove_custom_field)) + } + } + } + } + + OutlinedButton( + onClick = { + customFields = customFields + CustomMetadataField("", "") + customMetadataError = null + }, + enabled = customFields.size < 24, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.edit_song_add_custom_field)) + } + + Text( + text = stringResource(R.string.edit_song_custom_metadata_formats), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + customMetadataError?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + item { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( @@ -656,20 +830,33 @@ private fun EditSongContent( onClick = { val resolvedTrackNumber = trackNumber.toIntOrNull() ?: song.trackNumber val resolvedDiscNumber = discNumber.toIntOrNull() - onSave( - title.trim(), - artist.trim(), - album.trim(), - albumArtist.trim(), - composer.trim(), - genre.trim(), - lyrics, - resolvedTrackNumber, - resolvedDiscNumber, - replayGainTrackGainDb.trim(), - replayGainAlbumGainDb.trim(), - editedCoverArt - ) + buildCustomMetadataChanges( + metadataWasRead = metadataWasRead, + originalRating = originalRating, + editedRating = rating, + originalFields = originalCustomFields, + editedFields = customFields + ).onSuccess { customMetadataChanges -> + customMetadataError = null + onSave( + title.trim(), + artist.trim(), + album.trim(), + albumArtist.trim(), + composer.trim(), + genre.trim(), + lyrics, + resolvedTrackNumber, + resolvedDiscNumber, + replayGainTrackGainDb.trim(), + replayGainAlbumGainDb.trim(), + editedCoverArt, + customMetadataChanges + ) + }.onFailure { error -> + customMetadataError = error.message + ?: context.getString(R.string.edit_song_custom_metadata_invalid) + } }, modifier = Modifier.height(48.dp) ) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/SongInfoBottomSheet.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/SongInfoBottomSheet.kt index c85a8a22..85ebc8e4 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/SongInfoBottomSheet.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/SongInfoBottomSheet.kt @@ -83,6 +83,7 @@ import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.lostf1sh.pixelplayeross.data.media.CoverArtUpdate +import com.lostf1sh.pixelplayeross.data.media.CustomMetadataChanges import com.lostf1sh.pixelplayeross.presentation.viewmodel.SongInfoBottomSheetViewModel import com.lostf1sh.pixelplayeross.presentation.viewmodel.SongInfoBottomSheetViewModel.ToneTarget import kotlinx.coroutines.launch @@ -122,7 +123,8 @@ fun SongInfoBottomSheet( discNumber: Int?, replayGainTrackGainDb: String, replayGainAlbumGainDb: String, - coverArtUpdate: CoverArtUpdate? + coverArtUpdate: CoverArtUpdate?, + customMetadataChanges: CustomMetadataChanges ) -> Unit, removeFromListTrigger: () -> Unit, songInfoViewModel: SongInfoBottomSheetViewModel = hiltViewModel() @@ -853,7 +855,7 @@ fun SongInfoBottomSheet( visible = showEditSheet, song = song, onDismiss = { showEditSheet = false }, - onSave = { title, artist, album, albumArtist, composer, genre, lyrics, trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArt -> + onSave = { title, artist, album, albumArtist, composer, genre, lyrics, trackNumber, discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArt, customMetadataChanges -> onEditSong( title, artist, @@ -866,7 +868,8 @@ fun SongInfoBottomSheet( discNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArt + coverArt, + customMetadataChanges ) showEditSheet = false }, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AlbumDetailScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AlbumDetailScreen.kt index 2e5f23cc..0140afcd 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AlbumDetailScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AlbumDetailScreen.kt @@ -201,7 +201,9 @@ fun AlbumDetailScreen( uiState.album != null -> { val album = uiState.album!! val songs = uiState.songs - val cloudSongs = remember(songs) { songs.filter(CloudOfflineRepository::isCloudSong) } + val cloudSongs = remember(songs) { + CloudOfflineRepository.downloadCandidates(songs) + } val allCloudSongsDownloaded = remember(cloudSongs, completedOfflineUris) { cloudSongs.isNotEmpty() && cloudSongs.all { it.contentUriString in completedOfflineUris } } @@ -493,7 +495,7 @@ fun AlbumDetailScreen( } showSongInfoBottomSheet = false }, - onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate, customMetadataChanges -> playerViewModel.editSongMetadata( currentSong, newTitle, @@ -507,7 +509,8 @@ fun AlbumDetailScreen( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, removeFromListTrigger = removeFromListTrigger diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/ArtistDetailScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/ArtistDetailScreen.kt index bd124078..ef915ff2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/ArtistDetailScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/ArtistDetailScreen.kt @@ -515,7 +515,7 @@ fun ArtistDetailScreen( } showSongInfoBottomSheet = false }, - onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate, customMetadataChanges -> playerViewModel.editSongMetadata( currentSong, newTitle, @@ -529,7 +529,8 @@ fun ArtistDetailScreen( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, removeFromListTrigger = removeFromListTrigger diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/DailyMixScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/DailyMixScreen.kt index be9e1d83..053fa485 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/DailyMixScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/DailyMixScreen.kt @@ -177,7 +177,7 @@ fun DailyMixScreen( } showSongInfoSheet = false }, - onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate, customMetadataChanges -> playerViewModel.editSongMetadata( song, newTitle, @@ -191,7 +191,8 @@ fun DailyMixScreen( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, removeFromListTrigger = removeFromListTrigger diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/GenreDetailScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/GenreDetailScreen.kt index ca3fe16f..df87317d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/GenreDetailScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/GenreDetailScreen.kt @@ -501,7 +501,7 @@ fun GenreDetailScreen( } showSongOptionsSheet = null }, - onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate, customMetadataChanges -> playerViewModel.editSongMetadata( song, newTitle, @@ -515,7 +515,8 @@ fun GenreDetailScreen( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, removeFromListTrigger = {} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt index 5c486100..477337f2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt @@ -1658,7 +1658,7 @@ fun LibraryScreen( } showSongInfoBottomSheet = false }, - onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate, customMetadataChanges -> playerViewModel.editSongMetadata( currentSong, newTitle, @@ -1672,7 +1672,8 @@ fun LibraryScreen( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, removeFromListTrigger = {}, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt index 3ac6a2f9..0be957b7 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt @@ -1037,7 +1037,7 @@ fun PlaylistDetailScreen( } showSongInfoBottomSheet = false }, - onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate, customMetadataChanges -> playerViewModel.editSongMetadata( currentSong, newTitle, @@ -1051,7 +1051,8 @@ fun PlaylistDetailScreen( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, removeFromListTrigger = { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/RecentlyPlayedScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/RecentlyPlayedScreen.kt index 54533e1b..c688aef5 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/RecentlyPlayedScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/RecentlyPlayedScreen.kt @@ -318,7 +318,7 @@ fun RecentlyPlayedScreen( } showSongInfoBottomSheet = false }, - onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate, customMetadataChanges -> playerViewModel.editSongMetadata( song, newTitle, @@ -332,7 +332,8 @@ fun RecentlyPlayedScreen( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, removeFromListTrigger = {} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SearchScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SearchScreen.kt index 74c36c97..b1a90b1d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SearchScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SearchScreen.kt @@ -494,7 +494,7 @@ fun SearchScreen( } showSongInfoBottomSheet = false }, - onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate -> + onEditSong = { newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, newTrackNumber, newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, coverArtUpdate, customMetadataChanges -> playerViewModel.editSongMetadata( currentSong, newTitle, @@ -508,7 +508,8 @@ fun SearchScreen( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, ) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MetadataEditStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MetadataEditStateHolder.kt index 396f77e7..8cfca2b6 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MetadataEditStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MetadataEditStateHolder.kt @@ -3,6 +3,7 @@ package com.lostf1sh.pixelplayeross.presentation.viewmodel import android.content.Context import android.net.Uri import com.lostf1sh.pixelplayeross.data.media.CoverArtUpdate +import com.lostf1sh.pixelplayeross.data.media.CustomMetadataChanges import com.lostf1sh.pixelplayeross.data.media.ImageCacheManager import com.lostf1sh.pixelplayeross.data.media.MetadataEditError import com.lostf1sh.pixelplayeross.data.media.SongMetadataEditor @@ -65,7 +66,8 @@ class MetadataEditStateHolder @Inject constructor( newDiscNumber: Int?, newReplayGainTrackGainDb: String? = null, newReplayGainAlbumGainDb: String? = null, - coverArtUpdate: CoverArtUpdate? + coverArtUpdate: CoverArtUpdate?, + customMetadataChanges: CustomMetadataChanges = CustomMetadataChanges() ): MetadataEditResult = withContext(Dispatchers.IO) { Timber.tag("MetadataEditStateHolder").d("Starting saveMetadata for: ${song.title}") @@ -115,6 +117,7 @@ class MetadataEditStateHolder @Inject constructor( newDiscNumber = newDiscNumber, newReplayGainTrackGainDb = newReplayGainTrackGainDb, newReplayGainAlbumGainDb = newReplayGainAlbumGainDb, + customMetadataChanges = customMetadataChanges, coverArtUpdate = finalCoverArtUpdate, songId = resolvedSongId, ) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt index 9fa0f2dd..e7212525 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt @@ -39,6 +39,7 @@ import com.lostf1sh.pixelplayeross.R import com.lostf1sh.pixelplayeross.data.EotStateHolder import com.lostf1sh.pixelplayeross.data.database.AlbumArtThemeDao import com.lostf1sh.pixelplayeross.data.media.CoverArtUpdate +import com.lostf1sh.pixelplayeross.data.media.CustomMetadataChanges import com.lostf1sh.pixelplayeross.data.model.Album import com.lostf1sh.pixelplayeross.data.model.Artist import com.lostf1sh.pixelplayeross.data.model.FolderSource @@ -211,7 +212,8 @@ private data class PendingMetadataEdit( val discNumber: Int?, val replayGainTrackGainDb: String?, val replayGainAlbumGainDb: String?, - val coverArtUpdate: CoverArtUpdate? + val coverArtUpdate: CoverArtUpdate?, + val customMetadataChanges: CustomMetadataChanges ) private data class PendingBatchMetadataEdit( @@ -4321,6 +4323,7 @@ class PlayerViewModel @Inject constructor( newReplayGainTrackGainDb: String? = null, newReplayGainAlbumGainDb: String? = null, coverArtUpdate: CoverArtUpdate?, + customMetadataChanges: CustomMetadataChanges = CustomMetadataChanges(), ) { viewModelScope.launch { Timber.tag("PlayerViewModel").e("METADATA_EDIT_VM: Starting editSongMetadata via Holder") @@ -4343,7 +4346,8 @@ class PlayerViewModel @Inject constructor( discNumber = newDiscNumber, replayGainTrackGainDb = newReplayGainTrackGainDb, replayGainAlbumGainDb = newReplayGainAlbumGainDb, - coverArtUpdate = coverArtUpdate + coverArtUpdate = coverArtUpdate, + customMetadataChanges = customMetadataChanges ) _writePermissionRequest.emit(intentSender) return@launch @@ -4351,7 +4355,8 @@ class PlayerViewModel @Inject constructor( } performMetadataEdit(song, newTitle, newArtist, newAlbum, newAlbumArtist, newComposer, newGenre, newLyrics, - newTrackNumber, newDiscNumber, newReplayGainTrackGainDb, newReplayGainAlbumGainDb, coverArtUpdate) + newTrackNumber, newDiscNumber, newReplayGainTrackGainDb, newReplayGainAlbumGainDb, coverArtUpdate, + customMetadataChanges) } } @@ -4425,7 +4430,8 @@ class PlayerViewModel @Inject constructor( pending.song, pending.title, pending.artist, pending.album, pending.albumArtist, pending.composer, pending.genre, pending.lyrics, pending.trackNumber, pending.discNumber, - pending.replayGainTrackGainDb, pending.replayGainAlbumGainDb, pending.coverArtUpdate + pending.replayGainTrackGainDb, pending.replayGainAlbumGainDb, pending.coverArtUpdate, + pending.customMetadataChanges ) } } @@ -4489,6 +4495,7 @@ class PlayerViewModel @Inject constructor( newReplayGainTrackGainDb: String?, newReplayGainAlbumGainDb: String?, coverArtUpdate: CoverArtUpdate?, + customMetadataChanges: CustomMetadataChanges = CustomMetadataChanges(), ) { val previousAlbumArt = song.albumArtUriString @@ -4505,7 +4512,8 @@ class PlayerViewModel @Inject constructor( newDiscNumber = newDiscNumber, newReplayGainTrackGainDb = newReplayGainTrackGainDb, newReplayGainAlbumGainDb = newReplayGainAlbumGainDb, - coverArtUpdate = coverArtUpdate + coverArtUpdate = coverArtUpdate, + customMetadataChanges = customMetadataChanges ) Timber.tag("PlayerViewModel").e("METADATA_EDIT_VM: Result success=${result.success}") diff --git a/app/src/main/res/values-tr/strings_components.xml b/app/src/main/res/values-tr/strings_components.xml index 90c72f58..6a965d22 100644 --- a/app/src/main/res/values-tr/strings_components.xml +++ b/app/src/main/res/values-tr/strings_components.xml @@ -78,6 +78,16 @@ Şarkı meta verisi düzenleniyor Şarkıyı düzenle Seçilen görsel yüklenemedi + İsteğe bağlı meta veri + 0–5 arasında puan verin veya MOOD, COMMENT, YEAR ya da BPM gibi metin alanları ekleyin. + Puan (0–5) + Ayarlanmadı + Alan adı + Değer + Meta veri alanı ekle + Alanı kaldır + Puan ve özel alanlar MP3, WAV, AIFF, FLAC, OGG, Opus, M4A, M4B ve MP4 dosyalarına yazılabilir. + İsteğe bağlı meta veri alanlarını kontrol edin. Yeni olarak kaydet Ön ayar adı Ön ayarı yeniden adlandır diff --git a/app/src/main/res/values/strings_components.xml b/app/src/main/res/values/strings_components.xml index 99035e38..1f06e818 100644 --- a/app/src/main/res/values/strings_components.xml +++ b/app/src/main/res/values/strings_components.xml @@ -1,5 +1,5 @@ - + Tap to open Album art Album art placeholder @@ -80,6 +80,16 @@ Use pinch and drag gestures to find the perfect framing. Apply cover art Unable to load the selected image + Optional metadata + Set a 0–5 rating or add text fields such as MOOD, COMMENT, YEAR, or BPM. + Rating (0–5) + Not set + Field name + Value + Add metadata field + Remove field + Rating and custom fields can be written to MP3, WAV, AIFF, FLAC, OGG, Opus, M4A, M4B, and MP4 files. + Check the optional metadata fields. Share song file via Play song diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadataTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadataTest.kt new file mode 100644 index 00000000..7d54464a --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadataTest.kt @@ -0,0 +1,136 @@ +package com.lostf1sh.pixelplayeross.data.media + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CustomMetadataTest { + + @Test + fun `rating uses the native scale of each supported tag family`() { + assertEquals("0", encodeRatingForTag(0, MetadataTagFamily.ID3)) + assertEquals("255", encodeRatingForTag(5, MetadataTagFamily.ID3)) + assertEquals("80", encodeRatingForTag(4, MetadataTagFamily.MP4)) + assertEquals("3", encodeRatingForTag(3, MetadataTagFamily.VORBIS)) + + assertEquals(5, decodeRatingFromTag("255", MetadataTagFamily.ID3)) + assertEquals(4, decodeRatingFromTag("80", MetadataTagFamily.MP4)) + assertEquals(3, decodeRatingFromTag("3", MetadataTagFamily.VORBIS)) + } + + @Test + fun `container mapping limits custom writes to formats with a defined tag representation`() { + assertEquals(MetadataTagFamily.ID3, metadataTagFamily("mp3")) + assertEquals(MetadataTagFamily.VORBIS, metadataTagFamily("FLAC")) + assertEquals(MetadataTagFamily.MP4, metadataTagFamily(".m4a")) + assertEquals(MetadataTagFamily.UNSUPPORTED, metadataTagFamily("wma")) + } + + @Test + fun `validation normalizes safe custom keys and rejects built in fields`() { + val valid = validateCustomMetadataChanges( + CustomMetadataChanges( + rating = MetadataValueUpdate.Set(4), + fields = listOf(CustomMetadataFieldUpdate(" mood ", " Reflective ")) + ) + ).getOrThrow() + + assertEquals( + listOf(CustomMetadataFieldUpdate("MOOD", "Reflective")), + valid.fields + ) + + val reserved = validateCustomMetadataChanges( + CustomMetadataChanges( + fields = listOf(CustomMetadataFieldUpdate("album_artist", "Do not overwrite me")) + ) + ) + assertTrue(reserved.isFailure) + } + + @Test + fun `validation rejects duplicate keys and ratings outside zero to five`() { + val duplicates = validateCustomMetadataChanges( + CustomMetadataChanges( + fields = listOf( + CustomMetadataFieldUpdate("MOOD", "Quiet"), + CustomMetadataFieldUpdate("mood", "Loud") + ) + ) + ) + assertTrue(duplicates.isFailure) + + val invalidRating = validateCustomMetadataChanges( + CustomMetadataChanges(rating = MetadataValueUpdate.Set(6)) + ) + assertTrue(invalidRating.isFailure) + } + + @Test + fun `editor diff keeps unread rating and emits removals for deleted custom fields`() { + val unread = buildCustomMetadataChanges( + metadataWasRead = false, + originalRating = null, + editedRating = null, + originalFields = emptyList(), + editedFields = emptyList() + ).getOrThrow() + assertInstanceOf(MetadataValueUpdate.Keep::class.java, unread.rating) + + val zeroRating = buildCustomMetadataChanges( + metadataWasRead = true, + originalRating = null, + editedRating = 0, + originalFields = emptyList(), + editedFields = emptyList() + ).getOrThrow() + assertEquals(MetadataValueUpdate.Set(0), zeroRating.rating) + + val edited = buildCustomMetadataChanges( + metadataWasRead = true, + originalRating = 3, + editedRating = null, + originalFields = listOf( + CustomMetadataField("MOOD", "Reflective"), + CustomMetadataField("COMMENT", "Old") + ), + editedFields = listOf(CustomMetadataField("MOOD", "Energetic")) + ).getOrThrow() + + assertInstanceOf(MetadataValueUpdate.Clear::class.java, edited.rating) + assertEquals( + listOf( + CustomMetadataFieldUpdate("MOOD", "Energetic"), + CustomMetadataFieldUpdate("COMMENT", null) + ), + edited.fields + ) + assertTrue(edited.hasChanges) + } + + @Test + fun `property map exposes user fields without duplicating fixed metadata`() { + val fields = extractEditableCustomMetadataFields( + mapOf( + "TITLE" to arrayOf("Song"), + "ARTIST" to arrayOf("Artist"), + "RATING" to arrayOf("4"), + "MOOD" to arrayOf("Reflective"), + "----:com.apple.iTunes:LISTENING_CONTEXT" to arrayOf("Commute"), + "EMPTY" to arrayOf(""), + "MULTI" to arrayOf("A", "B") + ) + ) + + assertEquals( + listOf( + CustomMetadataField("LISTENING_CONTEXT", "Commute"), + CustomMetadataField("MOOD", "Reflective") + ), + fields + ) + assertFalse(fields.any { it.key == "TITLE" || it.key == "RATING" }) + } +} From 743ffbd061c3216883d88b174d2d4daf13316eee Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:10:40 +0300 Subject: [PATCH 12/18] Stabilize playback across system integrations (#112) Playback now keeps durable cloud URIs, refreshes offline resolution safely, caches queue snapshots, and exposes artwork through explicit grants for external media controllers. --- app/src/main/AndroidManifest.xml | 5 +- .../provider/SharedArtworkContentProvider.kt | 40 +++++++ .../data/service/MusicService.kt | 85 +++++++-------- .../data/service/PlaybackSnapshotItemCache.kt | 19 ++++ .../player/CloudPlaybackUriResolver.kt | 39 +++++++ .../data/service/player/DualPlayerEngine.kt | 58 +++++++--- .../SharedArtworkContentProviderTest.kt | 33 ++++++ .../service/PlaybackSnapshotItemCacheTest.kt | 30 +++++ .../player/CloudPlaybackUriResolverTest.kt | 103 ++++++++++++++++++ 9 files changed, 349 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/service/PlaybackSnapshotItemCache.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/CloudPlaybackUriResolver.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/service/PlaybackSnapshotItemCacheTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/service/player/CloudPlaybackUriResolverTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee3c72f2..098325ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -208,8 +208,9 @@ + android:exported="true" + android:grantUriPermissions="true" + tools:ignore="ExportedContentProvider" /> () private var isRestoringPlaybackSnapshot = false private var isPlaybackUnloadInProgress = false private val audioManager by lazy { @@ -1098,6 +1101,12 @@ class MusicService : MediaSessionService() { } override fun onTimelineChanged(timeline: Timeline, reason: Int) { + // Source timeline updates (for example, a newly prepared next item) do not change + // the queue metadata persisted here. Rebuilding hundreds of items for those updates + // puts the same work back on the next/previous transition hot path. + if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { + playbackSnapshotItemCache.invalidate() + } requestWidgetFullUpdate(force = true) schedulePlaybackSnapshotPersist(immediate = timeline.isEmpty) val player = engine.masterPlayer @@ -1327,42 +1336,8 @@ class MusicService : MediaSessionService() { return null } - val snapshotItems = ArrayList(mediaItemCount) - for (index in 0 until mediaItemCount) { - val mediaItem = player.getMediaItemAt(index) - val metadata = mediaItem.mediaMetadata - val playerUri = mediaItem.localConfiguration?.uri?.toString() - val originalContentUri = metadata.extras?.getString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI) - // A cloud item that resolved through a local stream proxy carries a loopback - // URL (http://127.0.0.1:{port}/{secret}/...) whose port and secret die with - // this process. Persist the original cloud URI from the extras instead so a - // restored queue re-resolves against the live proxy instead of failing with - // a source error. - val uri = when { - playerUri == null -> originalContentUri - isEphemeralLoopbackUri(playerUri) && !originalContentUri.isNullOrBlank() -> originalContentUri - else -> playerUri - } - - if (mediaItem.mediaId.isBlank() || uri.isNullOrBlank()) { - continue - } - - val durationMs = metadata.extras - ?.getLong(MediaItemBuilder.EXTERNAL_EXTRA_DURATION) - ?.takeIf { it > 0L } - - snapshotItems.add( - PlaybackQueueItemSnapshot( - mediaId = mediaItem.mediaId, - uri = uri, - title = metadata.title?.toString(), - artist = metadata.artist?.toString(), - albumTitle = metadata.albumTitle?.toString(), - artworkUri = resolveStoredArtworkUriString(metadata), - durationMs = durationMs, - ) - ) + val snapshotItems = playbackSnapshotItemCache.getOrBuild { + buildPlaybackSnapshotItems(player, mediaItemCount) } if (snapshotItems.isEmpty()) { @@ -1398,11 +1373,35 @@ class MusicService : MediaSessionService() { ) } - private fun isEphemeralLoopbackUri(uriString: String): Boolean { - val uri = runCatching { Uri.parse(uriString) }.getOrNull() ?: return false - val scheme = uri.scheme?.lowercase() - if (scheme != "http" && scheme != "https") return false - return uri.host == "127.0.0.1" || uri.host == "localhost" + private fun buildPlaybackSnapshotItems( + player: Player, + mediaItemCount: Int, + ): List { + val snapshotItems = ArrayList(mediaItemCount) + for (index in 0 until mediaItemCount) { + val mediaItem = player.getMediaItemAt(index) + val metadata = mediaItem.mediaMetadata + val playerUri = mediaItem.localConfiguration?.uri?.toString() + val originalContentUri = metadata.extras + ?.getString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI) + // Persist the provider URI for cloud items. Proxy ports die with the process and an + // app-private offline file can be removed while this item remains in the queue. + val uri = selectCanonicalCloudPlaybackUri(playerUri, originalContentUri) + if (mediaItem.mediaId.isBlank() || uri.isNullOrBlank()) continue + + snapshotItems += PlaybackQueueItemSnapshot( + mediaId = mediaItem.mediaId, + uri = uri, + title = metadata.title?.toString(), + artist = metadata.artist?.toString(), + albumTitle = metadata.albumTitle?.toString(), + artworkUri = resolveStoredArtworkUriString(metadata), + durationMs = metadata.extras + ?.getLong(MediaItemBuilder.EXTERNAL_EXTRA_DURATION) + ?.takeIf { it > 0L }, + ) + } + return snapshotItems } private suspend fun restorePlaybackQueueSnapshotIfNeeded() { @@ -2288,8 +2287,8 @@ class MusicService : MediaSessionService() { } /** - * The artwork provider is not exported, so controllers connected before the current item - * changed need a fresh grant for each new current item. + * Controllers connected before the current item changed need a fresh, narrowly scoped read + * grant for each new artwork URI. The provider enforces these grants when opening a file. */ private fun grantArtworkUriPermissionsToConnectedControllers(mediaItem: MediaItem) { val session = mediaSession ?: return diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/PlaybackSnapshotItemCache.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/PlaybackSnapshotItemCache.kt new file mode 100644 index 00000000..cf0631fe --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/PlaybackSnapshotItemCache.kt @@ -0,0 +1,19 @@ +package com.lostf1sh.pixelplayeross.data.service + +/** + * Keeps the immutable portion of a playback snapshot until the queue itself changes. + * Advancing to another item only changes index/position, so rebuilding every item's metadata on + * the player looper would add avoidable work to next/previous actions. + */ +internal class PlaybackSnapshotItemCache { + private var cachedItems: List? = null + + fun getOrBuild(builder: () -> List): List { + cachedItems?.let { return it } + return builder().also { cachedItems = it } + } + + fun invalidate() { + cachedItems = null + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/CloudPlaybackUriResolver.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/CloudPlaybackUriResolver.kt new file mode 100644 index 00000000..973ea643 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/CloudPlaybackUriResolver.kt @@ -0,0 +1,39 @@ +package com.lostf1sh.pixelplayeross.data.service.player + +import java.util.Locale + +/** + * Chooses the playable form of a canonical cloud URI without letting a cached proxy URL bypass an + * app-private offline copy. The generic shape keeps the ordering policy independently testable. + */ +internal suspend fun resolvePreferredCloudPlaybackValue( + original: T, + resolveOffline: suspend () -> T?, + resolveCachedRemote: () -> T?, + resolveRemote: suspend () -> T?, + onRemoteResolved: (T) -> Unit = {} +): T { + resolveOffline()?.let { return it } + resolveCachedRemote()?.let { return it } + return resolveRemote()?.also(onRemoteResolved) ?: original +} + +private val CANONICAL_CLOUD_SCHEMES = setOf("navidrome", "jellyfin") + +/** + * Keeps queue and persisted snapshot URIs independent from process-local proxy ports and from + * app-private downloads which can be removed while an item is still queued. + */ +internal fun selectCanonicalCloudPlaybackUri( + playerUri: String?, + originalContentUri: String?, +): String? = originalContentUri + ?.takeIf(::isCanonicalCloudPlaybackUri) + ?: playerUri + +internal fun isCanonicalCloudPlaybackUri(uri: String?): Boolean { + if (uri.isNullOrBlank()) return false + val separator = uri.indexOf(':') + if (separator <= 0) return false + return uri.substring(0, separator).lowercase(Locale.ROOT) in CANONICAL_CLOUD_SCHEMES +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt index c73144fb..b83ac614 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/player/DualPlayerEngine.kt @@ -38,6 +38,7 @@ import androidx.media3.extractor.flac.FlacExtractor import com.lostf1sh.pixelplayeross.data.model.AudioOutputMode import com.lostf1sh.pixelplayeross.data.model.TransitionSettings import com.lostf1sh.pixelplayeross.data.offline.CloudOfflineRepository +import com.lostf1sh.pixelplayeross.utils.MediaItemBuilder import com.lostf1sh.pixelplayeross.utils.envelope import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException @@ -1217,19 +1218,26 @@ class DualPlayerEngine @Inject constructor( suspend fun resolveCloudUri(uri: Uri): Uri = withContext(Dispatchers.IO) { val uriString = uri.toString() - resolvedUriCache.get(uriString)?.let { return@withContext it } - - val resolved: Uri? = when (uri.scheme) { - "navidrome" -> resolveNavidromeUriAsync(uriString) - "jellyfin" -> resolveJellyfinUriAsync(uriString) - else -> null - } - - if (resolved != null) { - resolvedUriCache.put(uriString, resolved) - return@withContext resolved - } - uri + resolvePreferredCloudPlaybackValue( + original = uri, + resolveOffline = { + try { + cloudOfflineRepository.resolveLocalUri(uriString) + } catch (error: Exception) { + Timber.tag("DualPlayerEngine").w(error, "Offline copy lookup failed") + null + } + }, + resolveCachedRemote = { resolvedUriCache.get(uriString) }, + resolveRemote = { + when (uri.scheme) { + "navidrome" -> resolveNavidromeUriAsync(uriString) + "jellyfin" -> resolveJellyfinUriAsync(uriString) + else -> null + } + }, + onRemoteResolved = { resolved -> resolvedUriCache.put(uriString, resolved) } + ) } private suspend fun resolveNavidromeUriAsync(uriString: String): Uri? = withContext(Dispatchers.IO) { @@ -1245,11 +1253,25 @@ class DualPlayerEngine @Inject constructor( } suspend fun resolveMediaItem(mediaItem: MediaItem): MediaItem { - val uri = mediaItem.localConfiguration?.uri ?: return mediaItem - val scheme = uri.scheme - if (scheme !in CLOUD_PROXY_SCHEMES) return mediaItem - val resolvedUri = resolveCloudUri(uri) - return if (resolvedUri == uri) mediaItem else mediaItem.buildUpon().setUri(resolvedUri).build() + val playerUri = mediaItem.localConfiguration?.uri ?: return mediaItem + val originalContentUri = mediaItem.mediaMetadata.extras + ?.getString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI) + val canonicalUriString = selectCanonicalCloudPlaybackUri( + playerUri = playerUri.toString(), + originalContentUri = originalContentUri, + ) + if (!isCanonicalCloudPlaybackUri(canonicalUriString)) return mediaItem + + val canonicalUri = Uri.parse(canonicalUriString) + // Warm the offline/proxy path, but keep the durable URI in the player's timeline. The + // ResolvingDataSource selects the current offline copy or proxy again for every load, so + // removing a download cannot strand a queued item on a stale file:// URI. + resolveCloudUri(canonicalUri) + return if (playerUri == canonicalUri) { + mediaItem + } else { + mediaItem.buildUpon().setUri(canonicalUri).build() + } } suspend fun prepareNext(target: TransitionTarget, startPositionMs: Long = 0L) { diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/provider/SharedArtworkContentProviderTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/provider/SharedArtworkContentProviderTest.kt index 1e4f2177..27f4c8b5 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/provider/SharedArtworkContentProviderTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/provider/SharedArtworkContentProviderTest.kt @@ -5,6 +5,39 @@ import org.junit.Test class SharedArtworkContentProviderTest { + @Test + fun artworkReadAccess_allowsProviderProcess() { + assertThat( + SharedArtworkContentProvider.hasArtworkReadAccess( + callingUid = 1001, + providerUid = 1001, + uriPermissionResult = android.content.pm.PackageManager.PERMISSION_DENIED, + ) + ).isTrue() + } + + @Test + fun artworkReadAccess_allowsExplicitUriGrant() { + assertThat( + SharedArtworkContentProvider.hasArtworkReadAccess( + callingUid = 2001, + providerUid = 1001, + uriPermissionResult = android.content.pm.PackageManager.PERMISSION_GRANTED, + ) + ).isTrue() + } + + @Test + fun artworkReadAccess_rejectsUntrustedExternalCaller() { + assertThat( + SharedArtworkContentProvider.hasArtworkReadAccess( + callingUid = 2001, + providerUid = 1001, + uriPermissionResult = android.content.pm.PackageManager.PERMISSION_DENIED, + ) + ).isFalse() + } + @Test fun buildSongUri_usesDedicatedArtworkAuthority() { val uri = SharedArtworkContentProvider.buildSongUriString( diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/PlaybackSnapshotItemCacheTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/PlaybackSnapshotItemCacheTest.kt new file mode 100644 index 00000000..96a404f7 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/PlaybackSnapshotItemCacheTest.kt @@ -0,0 +1,30 @@ +package com.lostf1sh.pixelplayeross.data.service + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class PlaybackSnapshotItemCacheTest { + @Test + fun `track advances reuse immutable queue items`() { + val cache = PlaybackSnapshotItemCache() + var builds = 0 + + val first = cache.getOrBuild { builds++; listOf("one", "two") } + val second = cache.getOrBuild { builds++; listOf("replacement") } + + assertThat(second).isSameInstanceAs(first) + assertThat(builds).isEqualTo(1) + } + + @Test + fun `queue mutation invalidates cached items`() { + val cache = PlaybackSnapshotItemCache() + cache.getOrBuild { listOf("one") } + + cache.invalidate() + + assertThat(cache.getOrBuild { listOf("one", "two") }) + .containsExactly("one", "two") + .inOrder() + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/player/CloudPlaybackUriResolverTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/player/CloudPlaybackUriResolverTest.kt new file mode 100644 index 00000000..5f93d4f2 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/player/CloudPlaybackUriResolverTest.kt @@ -0,0 +1,103 @@ +package com.lostf1sh.pixelplayeross.data.service.player + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class CloudPlaybackUriResolverTest { + @Test + fun `canonical jellyfin extra replaces a stale offline file uri`() { + assertThat( + selectCanonicalCloudPlaybackUri( + playerUri = "file:///deleted/offline.flac", + originalContentUri = "jellyfin://song-42", + ) + ).isEqualTo("jellyfin://song-42") + } + + @Test + fun `canonical navidrome extra replaces an ephemeral proxy uri`() { + assertThat( + selectCanonicalCloudPlaybackUri( + playerUri = "http://127.0.0.1:54321/stream", + originalContentUri = "navidrome://song-42", + ) + ).isEqualTo("navidrome://song-42") + } + + @Test + fun `local files remain local without a supported cloud extra`() { + assertThat( + selectCanonicalCloudPlaybackUri( + playerUri = "file:///music/local.flac", + originalContentUri = "https://example.test/not-a-provider-id", + ) + ).isEqualTo("file:///music/local.flac") + } + + + @Test + fun `offline copy wins before cached and newly resolved remote urls`() = runTest { + val calls = mutableListOf() + + val result = resolvePreferredCloudPlaybackValue( + original = "jellyfin://song", + resolveOffline = { + calls += "offline" + "file:///offline/song.flac" + }, + resolveCachedRemote = { + calls += "cache" + "http://127.0.0.1/cached" + }, + resolveRemote = { + calls += "remote" + "http://127.0.0.1/new" + } + ) + + assertThat(result).isEqualTo("file:///offline/song.flac") + assertThat(calls).containsExactly("offline") + } + + @Test + fun `cached remote url is used only when no offline copy exists`() = runTest { + var remoteResolutionCount = 0 + + val result = resolvePreferredCloudPlaybackValue( + original = "navidrome://song", + resolveOffline = { null }, + resolveCachedRemote = { "http://127.0.0.1/cached" }, + resolveRemote = { + remoteResolutionCount += 1 + "http://127.0.0.1/new" + } + ) + + assertThat(result).isEqualTo("http://127.0.0.1/cached") + assertThat(remoteResolutionCount).isEqualTo(0) + } + + @Test + fun `new remote url is cached and original uri remains the final fallback`() = runTest { + val cachedValues = mutableListOf() + + val remoteResult = resolvePreferredCloudPlaybackValue( + original = "navidrome://song", + resolveOffline = { null }, + resolveCachedRemote = { null }, + resolveRemote = { "http://127.0.0.1/new" }, + onRemoteResolved = { cachedValues += it } + ) + val fallbackResult = resolvePreferredCloudPlaybackValue( + original = "jellyfin://missing", + resolveOffline = { null }, + resolveCachedRemote = { null }, + resolveRemote = { null } + ) + + assertThat(remoteResult).isEqualTo("http://127.0.0.1/new") + assertThat(cachedValues).containsExactly("http://127.0.0.1/new") + assertThat(fallbackResult).isEqualTo("jellyfin://missing") + } +} From 14c4b221f3fd10c19225f535ef91dba511fcaeae Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:10:52 +0300 Subject: [PATCH 13/18] Reduce and trace navigation frame stalls (#114) Diagnostics now distinguish foreground timing and playback transitions, while search genre typography avoids repeated font loading on the first rendered frame. --- ...dvancedPerformanceDiagnosticsController.kt | 42 ++++++++++++++--- .../data/service/MusicService.kt | 6 +++ .../presentation/screens/SearchScreen.kt | 18 ++++++++ .../search/components/GenreCategoriesGrid.kt | 18 +------- .../search/components/GenreTypography.kt | 46 +++++++++++++++++++ .../presentation/viewmodel/PlayerViewModel.kt | 9 ++++ .../data/backup/BackupManagerTest.kt | 7 +++ ...cedPerformanceDiagnosticsControllerTest.kt | 14 ++++++ .../components/GenreTypographyFastTest.kt | 36 +++++++++++++++ 9 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/diagnostics/AdvancedPerformanceDiagnosticsControllerTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreTypographyFastTest.kt diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/diagnostics/AdvancedPerformanceDiagnosticsController.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/diagnostics/AdvancedPerformanceDiagnosticsController.kt index 00c4b31c..af421293 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/diagnostics/AdvancedPerformanceDiagnosticsController.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/diagnostics/AdvancedPerformanceDiagnosticsController.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicLong @Singleton class AdvancedPerformanceDiagnosticsController @Inject constructor( @@ -18,9 +19,14 @@ class AdvancedPerformanceDiagnosticsController @Inject constructor( private val stallMonitor = MainThreadStallMonitor() private var observerJob: Job? = null private var expiryJob: Job? = null + private var controllerScope: CoroutineScope? = null + private val monitorUpdateGeneration = AtomicLong(0L) + @Volatile private var sessionActive = false + @Volatile private var appForeground = false fun start(scope: CoroutineScope) { if (observerJob != null) return + controllerScope = scope observerJob = scope.launch { userPreferencesRepository.disableExpiredAdvancedPerformanceDiagnostics() userPreferencesRepository.advancedPerformanceDiagnosticsSettingsFlow.collectLatest { settings -> @@ -29,6 +35,7 @@ class AdvancedPerformanceDiagnosticsController @Inject constructor( startedAtEpochMs = settings.sessionStartedEpochMs, expiresAtEpochMs = settings.expiresAtEpochMs ) + sessionActive = active expiryJob?.cancel() if (active && settings.expiresAtEpochMs != null) { expiryJob = scope.launch { @@ -37,14 +44,35 @@ class AdvancedPerformanceDiagnosticsController @Inject constructor( userPreferencesRepository.disableExpiredAdvancedPerformanceDiagnostics() } } - withContext(Dispatchers.Main.immediate) { - if (active) { - stallMonitor.start() - } else { - stallMonitor.stop() - } - } + updateStallMonitor() + } + } + } + + fun onAppForeground() { + appForeground = true + updateStallMonitor() + } + + fun onAppBackground() { + appForeground = false + updateStallMonitor() + } + + private fun updateStallMonitor() { + val scope = controllerScope ?: return + val generation = monitorUpdateGeneration.incrementAndGet() + scope.launch { + withContext(Dispatchers.Main.immediate) { + if (generation != monitorUpdateGeneration.get()) return@withContext + val shouldRun = shouldRunMainThreadStallMonitor(sessionActive, appForeground) + if (shouldRun) stallMonitor.start() else stallMonitor.stop() } } } } + +internal fun shouldRunMainThreadStallMonitor( + sessionActive: Boolean, + appForeground: Boolean, +): Boolean = sessionActive && appForeground diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/MusicService.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/MusicService.kt index 0271ff01..fe62c1aa 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/MusicService.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/MusicService.kt @@ -1154,6 +1154,12 @@ class MusicService : MediaSessionService() { } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "media_item_transition", + ) { + mapOf("reason" to reason.toString()) + } mediaItem?.let(::grantArtworkUriPermissionsToConnectedControllers) syncLocalListeningStatsFromPlayer(mediaSession?.player ?: engine.masterPlayer, forceNewSession = true) if (isNavidromeMediaItem(mediaItem)) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SearchScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SearchScreen.kt index b1a90b1d..718e317c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SearchScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SearchScreen.kt @@ -86,6 +86,7 @@ import androidx.compose.material.icons.rounded.PlaylistPlay import androidx.compose.material.icons.rounded.History import androidx.compose.material3.TextButton import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.withFrameNanos import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.ime @@ -105,6 +106,7 @@ import androidx.compose.ui.res.painterResource import androidx.media3.common.util.UnstableApi import androidx.navigation.NavHostController import com.lostf1sh.pixelplayeross.R +import com.lostf1sh.pixelplayeross.data.diagnostics.AdvancedPerformanceDiagnostics import com.lostf1sh.pixelplayeross.data.repository.MusicRepository import com.lostf1sh.pixelplayeross.presentation.components.MiniPlayerHeight import com.lostf1sh.pixelplayeross.presentation.components.PlaylistBottomSheet @@ -143,6 +145,7 @@ fun SearchScreen( navController: NavHostController, onSearchBarActiveChange: (Boolean) -> Unit = {} ) { + val searchScreenEnteredAt = remember { android.os.SystemClock.elapsedRealtime() } var searchQuery by rememberSaveable { mutableStateOf(playerViewModel.searchQuery) } val statusBarTopInset = WindowInsets.systemBars.asPaddingValues().calculateTopPadding() val systemNavBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() @@ -170,7 +173,22 @@ fun SearchScreen( val searchInputFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.UI, + name = "search_screen_entered", + ) onSearchBarActiveChange(false) + withFrameNanos { } + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.UI, + name = "search_screen_first_frame", + ) { + mapOf( + "durationMs" to + (android.os.SystemClock.elapsedRealtime() - searchScreenEnteredAt).toString(), + "genreCount" to genres.size.toString(), + ) + } } LaunchedEffect(playerViewModel, keyboardController) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreCategoriesGrid.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreCategoriesGrid.kt index 72cf1de8..ece74c9b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreCategoriesGrid.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreCategoriesGrid.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio @@ -37,9 +36,6 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.ExperimentalTextApi -import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.media3.common.util.UnstableApi import com.lostf1sh.pixelplayeross.data.model.Genre @@ -182,33 +178,23 @@ private fun GenreCard( colors = CardDefaults.cardColors(containerColor = backgroundColor), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { - BoxWithConstraints( + Box( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(20.dp)) .background(backgroundColor) ) { - val textMeasurer = rememberTextMeasurer() - val density = LocalDensity.current val titleStartPadding = 14.dp val titleEndPadding = if (isGridView) 14.dp else 96.dp val titlePresentation = remember( genre.id, genre.name, isGridView, - maxWidth, - density.density, - density.fontScale ) { - val startPaddingPx = with(density) { titleStartPadding.roundToPx() } - val endPaddingPx = with(density) { titleEndPadding.roundToPx() } - GenreTypography.resolveTitlePresentation( + GenreTypography.resolveTitlePresentationFast( genreId = genre.id, genreName = genre.name, isGridView = isGridView, - cardWidthPx = with(density) { maxWidth.roundToPx() }, - horizontalPaddingPx = (startPaddingPx + endPaddingPx) / 2, - textMeasurer = textMeasurer ) } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreTypography.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreTypography.kt index 079d68b6..3a918c1e 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreTypography.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreTypography.kt @@ -104,6 +104,52 @@ object GenreTypography { return buildStyleCandidates(hash = hash, profile = profile, isGridView = true).first() } + /** + * Resolves the card title without synchronous text measurement. + * + * The previous card path tried several variable-font styles and every possible word break + * through TextMeasurer during composition. A handful of visible cards was enough to block a + * low-end device's first Search frame. Compose still applies ellipsis, so a deterministic + * character-weighted break preserves the expressive layout without main-thread probing. + */ + fun resolveTitlePresentationFast( + genreId: String, + genreName: String, + isGridView: Boolean, + ): TitlePresentation { + val normalizedName = genreName.trim().replace(Regex("\\s+"), " ") + val profile = GenreTitleProfile.from(normalizedName) + val hash = genreId.hashCode().toLong().absoluteValue + val styles = buildStyleCandidates(hash = hash, profile = profile, isGridView = isGridView) + val style = styles[ + when { + profile.isVeryDense -> styles.lastIndex + profile.isDense -> 1.coerceAtMost(styles.lastIndex) + else -> 0 + } + ] + val secondLineWidth = secondLineWidthFraction(profile, isGridView) + val words = normalizedName.split(' ').filter(String::isNotBlank) + val shouldSplit = isGridView && words.size > 1 && normalizedName.length > 12 + if (!shouldSplit) { + return TitlePresentation(normalizedName, null, style, secondLineWidth) + } + + val breakIndex = (1 until words.size).minByOrNull { index -> + val firstLength = words.take(index).sumOf(String::length) + index - 1 + val secondWordCount = words.size - index + val secondLength = words.drop(index).sumOf(String::length) + secondWordCount - 1 + kotlin.math.abs(firstLength * secondLineWidth - secondLength) + } ?: words.lastIndex + + return TitlePresentation( + firstLine = words.take(breakIndex).joinToString(" "), + secondLine = words.drop(breakIndex).joinToString(" "), + style = style, + secondLineWidthFraction = secondLineWidth, + ) + } + @OptIn(ExperimentalTextApi::class) private fun fitsSingleLine( text: String, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt index e7212525..b46a0110 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt @@ -122,6 +122,7 @@ import javax.inject.Inject import androidx.paging.PagingData import androidx.paging.cachedIn import coil.memory.MemoryCache +import com.lostf1sh.pixelplayeross.data.diagnostics.AdvancedPerformanceDiagnostics private const val ENABLE_FOLDERS_SOURCE_SWITCHING = true private const val MAX_ALBUM_BATCH_SELECTION = 6 @@ -3827,10 +3828,18 @@ class PlayerViewModel @Inject constructor( } fun nextSong() { + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "next_requested", + ) playbackStateHolder.nextSong() } fun previousSong() { + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "previous_requested", + ) playbackStateHolder.previousSong() } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/backup/BackupManagerTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/backup/BackupManagerTest.kt index fbb4b340..6e627fd1 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/backup/BackupManagerTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/backup/BackupManagerTest.kt @@ -3,6 +3,7 @@ package com.lostf1sh.pixelplayeross.data.backup import android.content.Context import android.net.Uri import com.lostf1sh.pixelplayeross.data.backup.format.BackupReader +import com.lostf1sh.pixelplayeross.data.backup.format.BackupFormatDetector import com.lostf1sh.pixelplayeross.data.backup.format.BackupWriter import com.lostf1sh.pixelplayeross.data.backup.history.BackupHistoryRepository import com.lostf1sh.pixelplayeross.data.backup.model.BackupManifest @@ -50,6 +51,12 @@ class BackupManagerTest { private val backupUri: Uri = mockk(relaxed = true) + init { + coEvery { backupReader.detectFormat(backupUri) } returns Result.success( + BackupFormatDetector.Format.PXPL_V3_ZIP + ) + } + @Test fun `inspectBackup surfaces file and module warnings in the restore plan`() = runTest { val plan = restorePlan(selectedModules = setOf(BackupSection.ENGAGEMENT_STATS)) diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/diagnostics/AdvancedPerformanceDiagnosticsControllerTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/diagnostics/AdvancedPerformanceDiagnosticsControllerTest.kt new file mode 100644 index 00000000..0cdb6ffe --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/diagnostics/AdvancedPerformanceDiagnosticsControllerTest.kt @@ -0,0 +1,14 @@ +package com.lostf1sh.pixelplayeross.data.diagnostics + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class AdvancedPerformanceDiagnosticsControllerTest { + @Test + fun `stall monitor runs only for an active foreground session`() { + assertThat(shouldRunMainThreadStallMonitor(true, true)).isTrue() + assertThat(shouldRunMainThreadStallMonitor(true, false)).isFalse() + assertThat(shouldRunMainThreadStallMonitor(false, true)).isFalse() + assertThat(shouldRunMainThreadStallMonitor(false, false)).isFalse() + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreTypographyFastTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreTypographyFastTest.kt new file mode 100644 index 00000000..ccbf8388 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/screens/search/components/GenreTypographyFastTest.kt @@ -0,0 +1,36 @@ +package com.lostf1sh.pixelplayeross.presentation.screens.search.components + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class GenreTypographyFastTest { + @Test + fun `long grid title gets deterministic balanced lines`() { + val first = GenreTypography.resolveTitlePresentationFast( + genreId = "rhythm-and-blues", + genreName = "Rhythm and Blues", + isGridView = true, + ) + val second = GenreTypography.resolveTitlePresentationFast( + genreId = "rhythm-and-blues", + genreName = "Rhythm and Blues", + isGridView = true, + ) + + assertThat(first.firstLine).isEqualTo("Rhythm and") + assertThat(first.secondLine).isEqualTo("Blues") + assertThat(second).isEqualTo(first) + } + + @Test + fun `list view keeps title on one line for compose ellipsis`() { + val result = GenreTypography.resolveTitlePresentationFast( + genreId = "progressive-metal", + genreName = "Progressive Metal", + isGridView = false, + ) + + assertThat(result.firstLine).isEqualTo("Progressive Metal") + assertThat(result.secondLine).isNull() + } +} From fb5f9722c52637c912e8bad5b95164ada9ea4c5a Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Tue, 1 Sep 2026 15:11:12 +0300 Subject: [PATCH 14/18] Download cloud playlists and selections offline (#90) Cloud songs can now be downloaded from playlist actions and batch selections, with duplicate and already-local entries filtered before work is queued. --- .../data/offline/CloudOfflineRepository.kt | 37 ++++++++--- .../components/MultiSelectionBottomSheet.kt | 2 +- .../presentation/screens/LibraryMediaTabs.kt | 21 ++++++- .../screens/PlaylistDetailScreen.kt | 28 +++++++++ .../viewmodel/CloudDownloadsViewModel.kt | 4 +- app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values/strings.xml | 3 +- .../offline/CloudOfflineRepositoryTest.kt | 63 +++++++++++++++++++ 8 files changed, 145 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/offline/CloudOfflineRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/offline/CloudOfflineRepository.kt index 3182f6e8..20224f85 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/offline/CloudOfflineRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/offline/CloudOfflineRepository.kt @@ -16,6 +16,7 @@ import com.lostf1sh.pixelplayeross.data.worker.CloudTrackDownloadWorker import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.security.MessageDigest +import java.util.Locale import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -78,9 +79,11 @@ class CloudOfflineRepository @Inject constructor( val provider = providerFor(song.contentUriString) ?: return@withLock val downloadId = downloadId(song.contentUriString) val existing = dao.getBySourceUri(song.contentUriString) - if (existing?.state == OfflineDownloadStatus.COMPLETE.storageValue && - existing.localPath?.let(::File)?.isFile == true - ) { + val completedFileAvailable = existing?.localPath + ?.let(::File) + ?.let { it.isFile && it.length() > 0L } + ?: false + if (!shouldStartNewAttempt(existing?.state, completedFileAvailable)) { return@withLock } existing?.let { @@ -121,10 +124,7 @@ class CloudOfflineRepository @Inject constructor( } suspend fun enqueueAll(songs: Collection) { - songs.asSequence() - .filter { isCloudSong(it) } - .distinctBy { it.contentUriString } - .forEach { enqueue(it) } + downloadCandidates(songs).forEach { enqueue(it) } } suspend fun retry(sourceUri: String) = withContext(Dispatchers.IO) { @@ -195,7 +195,14 @@ class CloudOfflineRepository @Inject constructor( companion object { fun isCloudSong(song: Song): Boolean = providerFor(song.contentUriString) != null - fun providerFor(sourceUri: String): String? = when (sourceUri.substringBefore(':', "").lowercase()) { + fun downloadCandidates(songs: Iterable): List = songs.asSequence() + .filter { isCloudSong(it) } + .distinctBy(Song::contentUriString) + .toList() + + fun providerFor(sourceUri: String): String? = when ( + sourceUri.substringBefore(':', "").lowercase(Locale.ROOT) + ) { "navidrome" -> "navidrome" "jellyfin" -> "jellyfin" else -> null @@ -208,6 +215,20 @@ class CloudOfflineRepository @Inject constructor( fun workName(downloadId: String): String = "cloud_track_download_$downloadId" + /** + * Album, playlist, and selection actions can all target the same track. Preserve an + * in-flight attempt instead of replacing it and discarding already-downloaded bytes. + */ + internal fun shouldStartNewAttempt( + existingState: String?, + completedFileAvailable: Boolean + ): Boolean = when (existingState) { + OfflineDownloadStatus.QUEUED.storageValue, + OfflineDownloadStatus.DOWNLOADING.storageValue -> false + OfflineDownloadStatus.COMPLETE.storageValue -> !completedFileAvailable + else -> true + } + internal fun attemptFileStem(downloadId: String, attemptId: String): String = "$downloadId.$attemptId" diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/MultiSelectionBottomSheet.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/MultiSelectionBottomSheet.kt index 14fd8e53..82a8a40b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/MultiSelectionBottomSheet.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/MultiSelectionBottomSheet.kt @@ -113,7 +113,7 @@ fun MultiSelectionBottomSheet( val context = LocalContext.current val sheetState = rememberModalSheetState(skipPartiallyExpanded = true) val cloudSongCount = remember(selectedSongs) { - selectedSongs.count(CloudOfflineRepository::isCloudSong) + CloudOfflineRepository.downloadCandidates(selectedSongs).size } val allAreLiked by remember(selectedSongs, favoriteSongIds) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryMediaTabs.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryMediaTabs.kt index 15b77546..3a31b72a 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryMediaTabs.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryMediaTabs.kt @@ -487,6 +487,11 @@ fun LibraryArtistsTab( onArtistClick: (Long) -> Unit, isRefreshing: Boolean, onRefresh: () -> Unit, + isSelectionMode: Boolean = false, + selectedArtistIds: Set = emptySet(), + onArtistLongPress: (Artist) -> Unit = {}, + onArtistSelectionToggle: (Artist) -> Unit = {}, + getSelectionIndex: (Long) -> Int? = { null }, storageFilter: StorageFilter = StorageFilter.ALL ) { val listState = rememberLazyListState() @@ -636,7 +641,21 @@ fun LibraryArtistsTab( val rememberedOnClick = remember(artist.id, onArtistClick) { { onArtistClick(artist.id) } } - ArtistListItem(artist = artist, onClick = rememberedOnClick) + val rememberedOnLongPress = remember(artist.id, onArtistLongPress) { + { onArtistLongPress(artist) } + } + val rememberedOnSelectionToggle = remember(artist.id, onArtistSelectionToggle) { + { onArtistSelectionToggle(artist) } + } + ArtistListItem( + artist = artist, + onClick = rememberedOnClick, + isSelectionMode = isSelectionMode, + isSelected = selectedArtistIds.contains(artist.id), + selectionIndex = getSelectionIndex(artist.id), + onLongPress = rememberedOnLongPress, + onSelectionToggle = rememberedOnSelectionToggle + ) } else { ArtistListItem( artist = Artist.empty(), diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt index 0be957b7..020707c0 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt @@ -1,5 +1,6 @@ package com.lostf1sh.pixelplayeross.presentation.screens +import android.widget.Toast import com.lostf1sh.pixelplayeross.presentation.navigation.navigateSafely import com.lostf1sh.pixelplayeross.presentation.navigation.navigateSafelyReplacing @@ -48,6 +49,7 @@ import androidx.compose.material.icons.filled.MusicOff import androidx.compose.material.icons.filled.RemoveCircleOutline import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.CloudDownload import androidx.compose.material.icons.rounded.DragIndicator import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Shuffle @@ -95,6 +97,7 @@ import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.Layout import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -125,6 +128,8 @@ import com.lostf1sh.pixelplayeross.presentation.components.resolveNavBarOccupied import com.lostf1sh.pixelplayeross.presentation.navigation.Screen import com.lostf1sh.pixelplayeross.presentation.viewmodel.PlayerViewModel import com.lostf1sh.pixelplayeross.presentation.viewmodel.PlaylistViewModel +import com.lostf1sh.pixelplayeross.presentation.viewmodel.CloudDownloadsViewModel +import com.lostf1sh.pixelplayeross.data.offline.CloudOfflineRepository import com.lostf1sh.pixelplayeross.presentation.viewmodel.PlaylistViewModel.Companion.FOLDER_PLAYLIST_PREFIX import com.lostf1sh.pixelplayeross.presentation.utils.LocalAppHapticsConfig import com.lostf1sh.pixelplayeross.presentation.utils.performAppCompatHapticFeedback @@ -157,6 +162,7 @@ fun PlaylistDetailScreen( onDeletePlayListClick: () -> Unit, playerViewModel: PlayerViewModel, playlistViewModel: PlaylistViewModel = hiltViewModel(), + cloudDownloadsViewModel: CloudDownloadsViewModel = hiltViewModel(), navController: NavController ) { val uiState by playlistViewModel.uiState.collectAsStateWithLifecycle() @@ -185,6 +191,7 @@ fun PlaylistDetailScreen( val deletePlaylistLabel = stringResource(R.string.presentation_batch_b_delete_playlist) val setDefaultTransitionLabel = stringResource(R.string.presentation_batch_b_set_default_transition) val exportPlaylistLabel = stringResource(R.string.presentation_batch_b_export_playlist) + val downloadPlaylistLabel = stringResource(R.string.cloud_playlist_download) val deletePlaylistConfirmTitle = stringResource(R.string.presentation_batch_b_delete_playlist_confirm_title) val deletePlaylistConfirmBody = stringResource(R.string.presentation_batch_b_delete_playlist_confirm_body) val sortSheetTitle = stringResource(R.string.presentation_batch_b_sort_songs) @@ -195,6 +202,9 @@ fun PlaylistDetailScreen( val isSmartPlaylist = currentPlaylist?.isSmartPlaylist == true val isEditablePlaylist = !isFolderPlaylist && !isSmartPlaylist val songsInPlaylist = uiState.currentPlaylistSongs + val cloudSongsInPlaylist = remember(songsInPlaylist) { + CloudOfflineRepository.downloadCandidates(songsInPlaylist) + } LaunchedEffect(playlistId) { playlistViewModel.loadPlaylistDetails(playlistId) @@ -889,6 +899,24 @@ fun PlaylistDetailScreen( navController.navigateSafely(Screen.EditTransition.createRoute(playlistId)) } ) + if (cloudSongsInPlaylist.isNotEmpty()) { + PlaylistActionItem( + icon = rememberVectorPainter(Icons.Rounded.CloudDownload), + label = downloadPlaylistLabel, + onClick = { + showPlaylistOptionsSheet = false + cloudDownloadsViewModel.downloadSelected(cloudSongsInPlaylist) + Toast.makeText( + context, + context.getString( + R.string.cloud_download_selected_started, + cloudSongsInPlaylist.size, + ), + Toast.LENGTH_SHORT, + ).show() + }, + ) + } PlaylistActionItem( icon = painterResource(R.drawable.rounded_attach_file_24), label = exportPlaylistLabel, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/CloudDownloadsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/CloudDownloadsViewModel.kt index 510051ca..4d0af092 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/CloudDownloadsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/CloudDownloadsViewModel.kt @@ -66,9 +66,7 @@ class CloudDownloadsViewModel @Inject constructor( } fun downloadSelected(songs: List) { - val cloudSongs = songs - .filter(CloudOfflineRepository::isCloudSong) - .distinctBy(Song::contentUriString) + val cloudSongs = CloudOfflineRepository.downloadCandidates(songs) if (cloudSongs.isEmpty()) return viewModelScope.launch { repository.enqueueAll(cloudSongs) } } diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 16194572..149fb5a3 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -220,6 +220,7 @@ İndirmeyi sil Yeniden dene Albümü indir + Çalma listesini indir İndirilenleri sil İndirmeler Çevrimdışı bulut parçalarını ve depolamayı yönet diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e3ffc191..b0727cd6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - + PixelPlayerOSS App Name Change This app is now PixelPlayerOSS. Keep playing! @@ -245,6 +245,7 @@ Remove download Retry Download album + Download playlist Remove downloads Downloads Manage offline cloud tracks and storage diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/offline/CloudOfflineRepositoryTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/offline/CloudOfflineRepositoryTest.kt index 805180d1..cf8887e2 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/offline/CloudOfflineRepositoryTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/offline/CloudOfflineRepositoryTest.kt @@ -9,6 +9,7 @@ class CloudOfflineRepositoryTest { fun `provider detection accepts only supported cloud schemes`() { assertThat(CloudOfflineRepository.providerFor("navidrome://track_1")).isEqualTo("navidrome") assertThat(CloudOfflineRepository.providerFor("jellyfin://ABC123")).isEqualTo("jellyfin") + assertThat(CloudOfflineRepository.providerFor("NAVIDROME://track_1")).isEqualTo("navidrome") assertThat(CloudOfflineRepository.providerFor("https://example.com/song.mp3")).isNull() assertThat(CloudOfflineRepository.providerFor("file:///music/song.mp3")).isNull() } @@ -32,6 +33,20 @@ class CloudOfflineRepositoryTest { assertThat(CloudOfflineRepository.isCloudSong(song("content://media/audio/1"))).isFalse() } + @Test + fun `download candidates keep both providers while excluding local songs and duplicate uris`() { + val navidrome = song("navidrome://one") + val duplicate = song("navidrome://one").copy(id = "duplicate") + val jellyfin = song("jellyfin://two") + val local = song("content://media/audio/3") + + val candidates = CloudOfflineRepository.downloadCandidates( + listOf(navidrome, duplicate, jellyfin, local) + ) + + assertThat(candidates).containsExactly(navidrome, jellyfin).inOrder() + } + @Test fun `separate attempts cannot share temporary or final file names`() { val downloadId = CloudOfflineRepository.downloadId("navidrome://track_1") @@ -44,5 +59,53 @@ class CloudOfflineRepositoryTest { assertThat("$first.flac").isNotEqualTo("$second.flac") } + @Test + fun `repeated batch actions do not replace queued or active downloads`() { + assertThat( + CloudOfflineRepository.shouldStartNewAttempt( + existingState = OfflineDownloadStatus.QUEUED.storageValue, + completedFileAvailable = false + ) + ).isFalse() + assertThat( + CloudOfflineRepository.shouldStartNewAttempt( + existingState = OfflineDownloadStatus.DOWNLOADING.storageValue, + completedFileAvailable = false + ) + ).isFalse() + } + + @Test + fun `complete download is reused only while its non-empty file is available`() { + assertThat( + CloudOfflineRepository.shouldStartNewAttempt( + existingState = OfflineDownloadStatus.COMPLETE.storageValue, + completedFileAvailable = true + ) + ).isFalse() + assertThat( + CloudOfflineRepository.shouldStartNewAttempt( + existingState = OfflineDownloadStatus.COMPLETE.storageValue, + completedFileAvailable = false + ) + ).isTrue() + } + + @Test + fun `failed and previously unseen downloads create a new attempt`() { + assertThat( + CloudOfflineRepository.shouldStartNewAttempt( + existingState = OfflineDownloadStatus.FAILED.storageValue, + completedFileAvailable = false + ) + ).isTrue() + assertThat( + CloudOfflineRepository.shouldStartNewAttempt( + existingState = null, + completedFileAvailable = false + ) + ).isTrue() + } + private fun song(uri: String) = Song.emptySong().copy(contentUriString = uri) } From 4a3d93747dfc9facb06d7381cbd788fc5d333a4b Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Sat, 5 Sep 2026 13:40:19 +0300 Subject: [PATCH 15/18] Fix automatic playlist exports to device storage Android added a playlist extension to temporary files, so synchronization rejected its own writes. Temporary documents now use a neutral MIME type until they are renamed after writing. --- .../pixelplayeross/data/playlist/M3uSyncRepository.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncRepository.kt index 2e0f2f53..2f7cda10 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncRepository.kt @@ -605,7 +605,7 @@ class M3uSyncRepository @Inject constructor( content: String, ): M3uDocument { val names = m3uReplacementNames(desiredName, UUID.randomUUID().toString()) - val temporary = createDocument(treeUri, names.temporary) + val temporary = createTemporaryDocument(treeUri, names.temporary) var temporaryUriForCleanup: Uri? = temporary.uri try { @@ -661,7 +661,7 @@ class M3uSyncRepository @Inject constructor( } } - private fun createDocument(treeUri: Uri, displayName: String): M3uDocument { + private fun createTemporaryDocument(treeUri: Uri, displayName: String): M3uDocument { val rootDocumentUri = DocumentsContract.buildDocumentUriUsingTree( treeUri, DocumentsContract.getTreeDocumentId(treeUri), @@ -669,7 +669,9 @@ class M3uSyncRepository @Inject constructor( val created = DocumentsContract.createDocument( resolver, rootDocumentUri, - M3U_MIME_TYPE, + // A playlist MIME type makes the system provider append .m3u to our .tmp name. + // Keep incomplete writes outside playlist scans until the final rename. + "application/octet-stream", displayName, ) ?: throw IOException("Unable to create temporary playlist file") return M3uDocument(created, displayName(created, displayName)) @@ -739,7 +741,6 @@ class M3uSyncRepository @Inject constructor( }.getOrNull()?.takeIf(String::isNotBlank) ?: fallback private companion object { - const val M3U_MIME_TYPE = "audio/x-mpegurl" val TREE_PERMISSION_FLAGS: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION From 1993a974aa5b7cc01a619f7c8020e469180c9bc6 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Sat, 5 Sep 2026 13:40:19 +0300 Subject: [PATCH 16/18] Keep repeated playlist tracks distinct while reordering Playlists containing a song more than once could crash because rows shared the same key. Each occurrence now keeps its own key as it moves through the list. --- .../screens/PlaylistDetailScreen.kt | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt index 020707c0..b23ed1d6 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -241,7 +240,14 @@ fun PlaylistDetailScreen( val navBarCompactMode by playerViewModel.navBarCompactMode.collectAsStateWithLifecycle() val bottomBarHeightDp = resolveNavBarOccupiedHeight(systemNavBarInset, navBarCompactMode) var showPlaylistBottomSheet by remember { mutableStateOf(false) } - var localReorderableSongs by remember(songsInPlaylist) { mutableStateOf(songsInPlaylist) } + // The initial position identifies an occurrence, even when the same song appears twice. + // Move the entry with its key so dragging never changes a row's identity. + var localReorderableEntries by remember(songsInPlaylist) { + mutableStateOf(songsInPlaylist.withIndex().toList()) + } + val localReorderableSongs = remember(localReorderableEntries) { + localReorderableEntries.map { it.value } + } val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -253,7 +259,7 @@ fun PlaylistDetailScreen( val reorderableState = rememberReorderableLazyListState( lazyListState = listState, onMove = { from, to -> - localReorderableSongs = localReorderableSongs.toMutableList().apply { + localReorderableEntries = localReorderableEntries.toMutableList().apply { add(to.index, removeAt(from.index)) } if (lastMovedFrom == null) { @@ -726,10 +732,11 @@ fun PlaylistDetailScreen( ) } ) { - itemsIndexed( - localReorderableSongs, - key = { _, item -> item.id }, - contentType = { _, _ -> "playlist_song" }) { _, song -> + items( + localReorderableEntries, + key = { it.index }, + contentType = { "playlist_song" }) { entry -> + val song = entry.value val playbackUiState by remember(song.id, playerViewModel) { playerViewModel.stablePlayerState .map { state -> @@ -743,7 +750,7 @@ fun PlaylistDetailScreen( }.collectAsStateWithLifecycle(initialValue = LibrarySongPlaybackUiState()) ReorderableItem( state = reorderableState, - key = song.id, + key = entry.index, ) { isDragging -> val scale by animateFloatAsState( if (isDragging) 1.05f else 1f, From 678b1290f05f65f23032071b082b9ca90cce5c0b Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Sat, 5 Sep 2026 13:40:20 +0300 Subject: [PATCH 17/18] Show batch actions when albums or artists are selected The action row retained the initial selection flags and stayed hidden after a long press. It now evaluates the current selection on recomposition. --- .../presentation/screens/LibraryScreen.kt | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt index 477337f2..c8b0436a 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt @@ -624,17 +624,13 @@ fun LibraryScreen( } } - val hasSelectionInCurrentTab by remember { - derivedStateOf { - when (currentTabId) { - LibraryTabId.PLAYLISTS -> isPlaylistSelectionMode - LibraryTabId.ALBUMS -> isAlbumSelectionMode - LibraryTabId.SONGS, - LibraryTabId.LIKED, - LibraryTabId.FOLDERS -> isSelectionMode - LibraryTabId.ARTISTS -> isArtistSelectionMode - } - } + val hasSelectionInCurrentTab = when (currentTabId) { + LibraryTabId.PLAYLISTS -> isPlaylistSelectionMode + LibraryTabId.ALBUMS -> isAlbumSelectionMode + LibraryTabId.SONGS, + LibraryTabId.LIKED, + LibraryTabId.FOLDERS -> isSelectionMode + LibraryTabId.ARTISTS -> isArtistSelectionMode } val canHandleFolderBack by remember { derivedStateOf { From fe8a6be1d444c6ab6623e43382e36ca7d24a422f Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Sat, 5 Sep 2026 13:40:20 +0300 Subject: [PATCH 18/18] Save release date edits to the native audio tag The editor read release dates as DATE but the writer expected YEAR, leaving the actual date unchanged. The writer now translates that name before updating or removing the tag. Regression tests cover editing and removing dates in MP3 and MP4 tags. --- .../data/media/SongMetadataEditor.kt | 8 ++++-- .../data/media/CustomMetadataTest.kt | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt index 17bc5dd1..3e5a8015 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt @@ -1337,9 +1337,13 @@ private fun Tag.applyCustomMetadataChanges( } } -private fun Tag.applyCustomMetadataField(update: CustomMetadataFieldUpdate) { +internal fun Tag.applyCustomMetadataField(update: CustomMetadataFieldUpdate) { val standardFieldKey = runCatching { - FieldKey.valueOf(update.key.replace(Regex("[ .-]+"), "_")) + // TagLib exposes native release dates as DATE; JAudioTagger names that field YEAR. + when (update.key) { + "DATE" -> FieldKey.YEAR + else -> FieldKey.valueOf(update.key.replace(Regex("[ .-]+"), "_")) + } }.getOrNull() if (standardFieldKey != null) { diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadataTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadataTest.kt index 7d54464a..2d85754a 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadataTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadataTest.kt @@ -5,9 +5,34 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.jaudiotagger.tag.FieldKey +import org.jaudiotagger.tag.id3.ID3v24Tag +import org.jaudiotagger.tag.mp4.Mp4Tag class CustomMetadataTest { + @Test + fun `editing the displayed DATE updates the native release date`() { + listOf(ID3v24Tag(), Mp4Tag()).forEach { tag -> + tag.setField(FieldKey.YEAR, "2020") + + tag.applyCustomMetadataField(CustomMetadataFieldUpdate("DATE", "2024")) + + assertEquals("2024", tag.getFirst(FieldKey.YEAR), tag.javaClass.simpleName) + } + } + + @Test + fun `removing the displayed DATE removes the native release date`() { + listOf(ID3v24Tag(), Mp4Tag()).forEach { tag -> + tag.setField(FieldKey.YEAR, "2020") + + tag.applyCustomMetadataField(CustomMetadataFieldUpdate("DATE", null)) + + assertEquals("", tag.getFirst(FieldKey.YEAR), tag.javaClass.simpleName) + } + } + @Test fun `rating uses the native scale of each supported tag family`() { assertEquals("0", encodeRatingForTag(0, MetadataTagFamily.ID3))