From 3cbb75bd21dc4dfd37b43d1fb1bcec3b7c3922a7 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Thu, 18 Jun 2026 01:54:51 +0300 Subject: [PATCH 1/4] Trust user-installed CAs for self-signed cloud servers Self-hosted Navidrome/Subsonic/Jellyfin servers commonly use a private or self-signed CA. Release builds previously trusted only system CAs, so a root certificate the user imported via Android Settings was ignored and the connection was refused. Add to the release network security config so the app honors user-installed CAs. This is a declarative, user-controlled trust decision (the user must deliberately install the cert), not a validation bypass, and keeps F-Droid compliance intact. Fixes #7 --- app/src/main/res/xml/network_security_config.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index e94e6a0..a943465 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,9 +1,17 @@ + + From 726c7a8921d513cd69f1956aee25ff22e8e422d4 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Thu, 18 Jun 2026 01:55:01 +0300 Subject: [PATCH 2/4] Group Artists tab and cloud albums by album artist "Group by Album Artist" was effectively ignored for Subsonic/Navidrome and Jellyfin libraries, and never decluttered the Artists tab for any source. Two problems: - Cloud sync hardcoded albumArtist = null, so the album-artist tag the Subsonic (albumArtist) and Jellyfin (AlbumArtist) APIs return was dropped and the setting could not work for cloud albums. - The preference only relabeled album entities; the Artists tab always listed every track-level artist, so featured/compilation artists cluttered it even for local libraries. Changes: - Capture the album-artist tag end to end for Navidrome and Jellyfin (API parser -> DTO -> cache entity -> unified song). - Add songs.album_artist_id, the id of the effective album artist (album_artist when present, else the primary track artist), populated at sync for local and both cloud sources. Album-artist-only names (e.g. "Various Artists") get a real artist row and are preserved by deleteOrphanedArtists. - When the toggle is on, the Artists tab and artist detail collapse onto album_artist_id. The column is preference-independent, so toggling switches queries at runtime with no re-sync. Default (toggle off) behavior is unchanged. - MIGRATION_1_2 (v1 -> v2) adds the columns with defensive PRAGMA guards and backfills album_artist_id from the existing primary artist. Closes #8 --- .../2.json | 2036 +++++++++++++++++ .../data/database/JellyfinSongEntity.kt | 2 + .../data/database/Migrations.kt | 54 + .../pixelplayeross/data/database/MusicDao.kt | 67 +- .../data/database/NavidromeSongEntity.kt | 2 + .../data/database/PixelPlayerDatabase.kt | 2 +- .../data/database/SongEntity.kt | 5 + .../data/jellyfin/JellyfinRepository.kt | 35 +- .../data/jellyfin/model/JellyfinSong.kt | 2 + .../data/navidrome/NavidromeRepository.kt | 35 +- .../data/navidrome/model/NavidromeSong.kt | 2 + .../jellyfin/JellyfinResponseParser.kt | 4 +- .../navidrome/NavidromeResponseParser.kt | 1 + .../data/repository/MusicRepositoryImpl.kt | 43 +- .../pixelplayeross/data/worker/SyncWorker.kt | 15 + .../lostf1sh/pixelplayeross/di/AppModule.kt | 2 + .../values/strings_presentation_batch_g.xml | 2 +- 17 files changed, 2282 insertions(+), 27 deletions(-) create mode 100644 app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/2.json create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt diff --git a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/2.json b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/2.json new file mode 100644 index 0000000..eafbe54 --- /dev/null +++ b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/2.json @@ -0,0 +1,2036 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "45d157a424596f5eac77b9f085a88bbd", + "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, 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" + } + ], + "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`, `song_id`))", + "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", + "song_id" + ] + }, + "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" + ] + } + } + ], + "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, '45d157a424596f5eac77b9f085a88bbd')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/JellyfinSongEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/JellyfinSongEntity.kt index 48dff68..bacbc1c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/JellyfinSongEntity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/JellyfinSongEntity.kt @@ -22,6 +22,7 @@ data class JellyfinSongEntity( val title: String, val artist: String, @ColumnInfo(name = "artist_id") val artistId: String?, + @ColumnInfo(name = "album_artist") val albumArtist: String? = null, val album: String, @ColumnInfo(name = "album_id") val albumId: String?, val duration: Long, @@ -66,6 +67,7 @@ fun JellyfinSong.toEntity(playlistId: String): JellyfinSongEntity { title = title, artist = artist, artistId = artistId, + albumArtist = albumArtist, album = album, albumId = albumId, duration = duration, 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 new file mode 100644 index 0000000..ea18809 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt @@ -0,0 +1,54 @@ +package com.lostf1sh.pixelplayeross.data.database + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Returns true if [table] already has a column named [column]. + * + * Platform Auto Backup can restore a database that reports the right schema version but whose + * columns have drifted, so migrations guard each `ALTER` with this check instead of assuming a + * bare `ADD COLUMN` is safe (see the migration notes in CLAUDE.md / CONTRIBUTING). + */ +private fun SupportSQLiteDatabase.hasColumn(table: String, column: String): Boolean { + query("PRAGMA table_info(`$table`)").use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + if (nameIndex < 0) return false + while (cursor.moveToNext()) { + if (cursor.getString(nameIndex) == column) return true + } + } + return false +} + +private fun SupportSQLiteDatabase.addColumnIfMissing(table: String, column: String, ddl: String) { + if (!hasColumn(table, column)) { + execSQL("ALTER TABLE `$table` ADD COLUMN $ddl") + } +} + +/** + * v1 -> v2: album-artist support for the unified library (issue #8). + * + * - `songs.album_artist_id`: id of the *effective* album artist (the song's `album_artist` when + * present, otherwise its primary track artist). Source-independent, so the "Group by Album + * Artist" Artists tab can collapse on it at runtime without forcing a re-sync. + * - `navidrome_songs.album_artist` / `jellyfin_songs.album_artist`: carry the server's + * album-artist tag through the cloud cache so the unified projection can populate the above. + * + * Additive and idempotent — each column is added only when missing. `album_artist_id` is then + * backfilled to the existing primary artist so the collapsed Artists tab is populated before the + * next library sync recomputes precise values (e.g. collapsing compilations under one artist). + */ +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.addColumnIfMissing("songs", "album_artist_id", "`album_artist_id` INTEGER NOT NULL DEFAULT 0") + db.addColumnIfMissing("navidrome_songs", "album_artist", "`album_artist` TEXT") + db.addColumnIfMissing("jellyfin_songs", "album_artist", "`album_artist` TEXT") + + // Seed from the existing primary artist so the collapsed tab is non-empty immediately. + db.execSQL("UPDATE songs SET album_artist_id = artist_id WHERE album_artist_id = 0") + + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_album_artist_id` ON `songs` (`album_artist_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 19f6d09..b2c52a3 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 @@ -47,6 +47,7 @@ private const val SONG_DETAIL_PROJECTION = """ songs.artist_name AS artist_name, songs.artist_id AS artist_id, songs.album_artist AS album_artist, + songs.album_artist_id AS album_artist_id, songs.album_name AS album_name, songs.album_id AS album_id, songs.content_uri_string AS content_uri_string, @@ -77,7 +78,7 @@ private const val SONG_DETAIL_PROJECTION = """ // Projection for list queries: excludes lyrics to prevent CursorWindow overflow (2MB limit) // when loading large libraries. Lyrics are only needed for the Now Playing screen (getSongById). private const val SONG_LIST_PROJECTION = """ - id, title, artist_name, artist_id, album_artist, album_name, album_id, + id, title, artist_name, artist_id, album_artist, album_artist_id, album_name, album_id, content_uri_string, album_art_uri_string, duration, genre, file_path, parent_directory_path, is_favorite, NULL AS lyrics, track_number, disc_number, year, date_added, mime_type, bitrate, sample_rate, artists_json, source_type, @@ -1324,6 +1325,46 @@ interface MusicDao { sortOrder: String ): PagingSource + /** + * Album-artist variant of [getArtistsPaginated], used when "Group by Album Artist" is on. + * Collapses the Artists tab onto each song's effective album artist (songs.album_artist_id) + * instead of every track-level artist, so featured/compilation artists stop cluttering the + * list. Counts are per-album-artist; sorting and source/directory filtering mirror the + * track-artist query so toggling the preference only swaps which query backs the tab. + */ + @Query(""" + SELECT artists.id, artists.name, artists.image_url, artists.custom_image_uri, + COUNT(DISTINCT songs.id) AS track_count + FROM songs + INNER JOIN artists ON artists.id = songs.album_artist_id + WHERE (:applyDirectoryFilter = 0 OR songs.id < 0 OR songs.parent_directory_path IN (:allowedParentDirs)) + AND ( + :filterMode = 0 + OR ( + :filterMode = 1 + AND songs.source_type = 0 + ) + OR ( + :filterMode = 2 + AND songs.source_type != 0 + ) + ) + GROUP BY artists.id + ORDER BY + CASE WHEN :sortOrder = 'artist_name_az' THEN artists.name END COLLATE NOCASE ASC, + CASE WHEN :sortOrder = 'artist_name_za' THEN artists.name END COLLATE NOCASE DESC, + CASE WHEN :sortOrder = 'artist_num_songs_desc' THEN track_count END DESC, + CASE WHEN :sortOrder = 'artist_num_songs_asc' THEN track_count END ASC, + artists.name COLLATE NOCASE ASC, + artists.id ASC + """) + fun getArtistsPaginatedByAlbumArtist( + allowedParentDirs: List, + applyDirectoryFilter: Boolean, + filterMode: Int, + sortOrder: String + ): PagingSource + @Query(""" SELECT artists.id, artists.name, artists.image_url, artists.custom_image_uri, COUNT(DISTINCT songs.id) AS track_count @@ -1498,15 +1539,18 @@ interface MusicDao { suspend fun deleteOrphanedAlbums() /** - * An artist is only orphaned when nothing references it: neither the cross-ref table - * nor songs.artist_id. The songs.artist_id check is load-bearing — the songs FK is - * declared ON DELETE SET NULL but the column is NOT NULL, so deleting an artist that - * a song still points at would abort with a constraint error instead of nulling. + * An artist is only orphaned when nothing references it: not the cross-ref table, not + * songs.artist_id, and not songs.album_artist_id. The songs.artist_id check is load-bearing + * — the songs FK is declared ON DELETE SET NULL but the column is NOT NULL, so deleting an + * artist a song still points at would abort with a constraint error instead of nulling. The + * album_artist_id check keeps album-artist-only rows (e.g. "Various Artists", which never + * appear as a track artist and so have no cross-ref) alive for the "Group by Album Artist" tab. */ @Query(""" DELETE FROM artists WHERE NOT EXISTS (SELECT 1 FROM song_artist_cross_ref WHERE song_artist_cross_ref.artist_id = artists.id) AND NOT EXISTS (SELECT 1 FROM songs WHERE songs.artist_id = artists.id) + AND NOT EXISTS (SELECT 1 FROM songs WHERE songs.album_artist_id = artists.id) """) suspend fun deleteOrphanedArtists() @@ -1681,6 +1725,19 @@ interface MusicDao { """) fun getSongsForArtist(artistId: Long): Flow> + /** + * Album-artist variant of [getSongsForArtist]: every song whose effective album artist is + * [artistId]. Used by the artist detail screen when "Group by Album Artist" is on so the + * detail view stays consistent with the collapsed Artists tab (tapping "Various Artists" + * shows the compilation tracks rather than an empty screen). + */ + @Query(""" + SELECT * FROM songs + WHERE album_artist_id = :artistId + ORDER BY title ASC + """) + fun getSongsForArtistByAlbumArtist(artistId: Long): Flow> + /** * Get all songs for a specific artist (one-shot). */ diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt index eee3cab..797c3d9 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt @@ -45,6 +45,7 @@ data class NavidromeSongEntity( val title: String, val artist: String, @ColumnInfo(name = "artist_id") val artistId: String?, + @ColumnInfo(name = "album_artist") val albumArtist: String? = null, val album: String, @ColumnInfo(name = "album_id") val albumId: String?, @ColumnInfo(name = "cover_art_id") val coverArtId: String?, @@ -98,6 +99,7 @@ fun NavidromeSong.toEntity(playlistId: String): NavidromeSongEntity { title = title, artist = artist, artistId = artistId, + albumArtist = albumArtist, album = album, albumId = albumId, coverArtId = coverArt, 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 59a125a..21871c6 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 @@ -24,7 +24,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase JellyfinSongEntity::class, JellyfinPlaylistEntity::class ], - version = 1, + version = 2, exportSchema = true ) abstract class PixelPlayerDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt index 48f5957..9302fa5 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt @@ -41,6 +41,7 @@ object SourceType { Index(value = ["date_added"], unique = false), Index(value = ["duration"], unique = false), Index(value = ["source_type"], unique = false), + Index(value = ["album_artist_id"], unique = false), Index(value = ["parent_directory_path", "source_type", "album_id"], unique = false), Index(value = ["parent_directory_path", "source_type", "id"], unique = false) ], @@ -67,6 +68,10 @@ data class SongEntity( @ColumnInfo(name = "artist_name") val artistName: String, // Display string (combined or primary) @ColumnInfo(name = "artist_id") val artistId: Long, // Primary artist ID for backward compatibility @ColumnInfo(name = "album_artist") val albumArtist: String? = null, // Album artist from metadata + // Id of the *effective* album artist (album_artist when present, else the primary track + // artist). Source-independent and computed at sync time so the "Group by Album Artist" + // Artists tab can collapse on it at runtime without a re-sync. 0 = unresolved. + @ColumnInfo(name = "album_artist_id", defaultValue = "0") val albumArtistId: Long = 0L, @ColumnInfo(name = "album_name") val albumName: String, @ColumnInfo(name = "album_id") val albumId: Long, // index = true removed @ColumnInfo(name = "content_uri_string") val contentUriString: String, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt index 89bbfb1..94a7e37 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt @@ -22,6 +22,7 @@ import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.network.jellyfin.JellyfinApiService import com.lostf1sh.pixelplayeross.data.network.jellyfin.JellyfinResponseParser import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository +import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.data.stream.BulkSyncResult import com.lostf1sh.pixelplayeross.data.stream.CloudMusicUtils import dagger.hilt.android.qualifiers.ApplicationContext @@ -45,6 +46,7 @@ class JellyfinRepository @Inject constructor( private val dao: JellyfinDao, private val musicDao: MusicDao, private val playlistPreferencesRepository: PlaylistPreferencesRepository, + private val userPreferencesRepository: UserPreferencesRepository, @ApplicationContext private val context: Context ) { private companion object { @@ -496,6 +498,10 @@ class JellyfinRepository @Inject constructor( return } + // When on, "Group by Album Artist" makes the album's display artist the album artist; + // either way the effective album artist is captured on the song for the Artists tab. + val groupByAlbumArtist = userPreferencesRepository.groupByAlbumArtistFlow.first() + val songs = ArrayList(jellyfinSongs.size) val artists = LinkedHashMap() val albums = LinkedHashMap() @@ -507,6 +513,25 @@ class JellyfinRepository @Inject constructor( val primaryArtistName = artistNames.firstOrNull() ?: "Unknown Artist" val primaryArtistId = toUnifiedArtistId(primaryArtistName) + // Effective album artist (Jellyfin AlbumArtist, else primary track artist), registered + // as a real artist row so songs.album_artist_id can join to it. + val effectiveAlbumArtistName = jellyfinSong.albumArtist + ?.trim() + ?.takeIf { it.isNotBlank() } + ?: primaryArtistName + val albumArtistId = toUnifiedArtistId(effectiveAlbumArtistName) + artists.putIfAbsent( + albumArtistId, + ArtistEntity( + id = albumArtistId, + name = effectiveAlbumArtistName, + trackCount = 0, + imageUrl = null + ) + ) + val albumDisplayArtistName = if (groupByAlbumArtist) effectiveAlbumArtistName else primaryArtistName + val albumDisplayArtistId = if (groupByAlbumArtist) albumArtistId else primaryArtistId + artistNames.forEachIndexed { index, artistName -> val artistId = toUnifiedArtistId(artistName) artists.putIfAbsent( @@ -534,12 +559,13 @@ class JellyfinRepository @Inject constructor( AlbumEntity( id = albumId, title = albumName, - artistName = primaryArtistName, - artistId = primaryArtistId, + artistName = albumDisplayArtistName, + artistId = albumDisplayArtistId, songCount = 0, dateAdded = jellyfinSong.dateAdded, year = jellyfinSong.year, - albumArtUriString = "jellyfin_cover://${jellyfinSong.jellyfinId}" + albumArtUriString = "jellyfin_cover://${jellyfinSong.jellyfinId}", + albumArtist = jellyfinSong.albumArtist?.takeIf { it.isNotBlank() } ) ) @@ -549,7 +575,8 @@ class JellyfinRepository @Inject constructor( title = jellyfinSong.title, artistName = jellyfinSong.artist.ifBlank { primaryArtistName }, artistId = primaryArtistId, - albumArtist = null, + albumArtist = jellyfinSong.albumArtist?.takeIf { it.isNotBlank() }, + albumArtistId = albumArtistId, albumName = albumName, albumId = albumId, contentUriString = "jellyfin://${jellyfinSong.jellyfinId}", diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/model/JellyfinSong.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/model/JellyfinSong.kt index be96837..5322a88 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/model/JellyfinSong.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/model/JellyfinSong.kt @@ -11,6 +11,7 @@ data class JellyfinSong( val title: String, val artist: String, val artistId: String? = null, + val albumArtist: String? = null, val album: String, val albumId: String? = null, val duration: Long, // milliseconds @@ -30,6 +31,7 @@ data class JellyfinSong( title = "", artist = "", artistId = null, + albumArtist = null, album = "", albumId = null, duration = 0L, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt index b311cc1..109e629 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt @@ -23,6 +23,7 @@ import com.lostf1sh.pixelplayeross.data.navidrome.model.NavidromeSong import com.lostf1sh.pixelplayeross.data.network.navidrome.NavidromeApiService import com.lostf1sh.pixelplayeross.data.network.navidrome.NavidromeResponseParser import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository +import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.data.stream.BulkSyncResult import com.lostf1sh.pixelplayeross.data.stream.CloudMusicUtils import dagger.hilt.android.qualifiers.ApplicationContext @@ -60,6 +61,7 @@ class NavidromeRepository @Inject constructor( private val dao: NavidromeDao, private val musicDao: MusicDao, private val playlistPreferencesRepository: PlaylistPreferencesRepository, + private val userPreferencesRepository: UserPreferencesRepository, @ApplicationContext private val context: Context ) { companion object { @@ -735,6 +737,10 @@ class NavidromeRepository @Inject constructor( return } + // When on, "Group by Album Artist" makes the album's display artist the album artist; + // either way the effective album artist is captured on the song for the Artists tab. + val groupByAlbumArtist = userPreferencesRepository.groupByAlbumArtistFlow.first() + val songs = ArrayList(navidromeSongs.size) val artists = LinkedHashMap() val albums = LinkedHashMap() @@ -746,6 +752,25 @@ class NavidromeRepository @Inject constructor( val primaryArtistName = artistNames.firstOrNull() ?: "Unknown Artist" val primaryArtistId = toUnifiedArtistId(primaryArtistName) + // Effective album artist (Subsonic albumArtist tag, else primary track artist), + // registered as a real artist row so songs.album_artist_id can join to it. + val effectiveAlbumArtistName = navidromeSong.albumArtist + ?.trim() + ?.takeIf { it.isNotBlank() } + ?: primaryArtistName + val albumArtistId = toUnifiedArtistId(effectiveAlbumArtistName) + artists.putIfAbsent( + albumArtistId, + ArtistEntity( + id = albumArtistId, + name = effectiveAlbumArtistName, + trackCount = 0, + imageUrl = null + ) + ) + val albumDisplayArtistName = if (groupByAlbumArtist) effectiveAlbumArtistName else primaryArtistName + val albumDisplayArtistId = if (groupByAlbumArtist) albumArtistId else primaryArtistId + artistNames.forEachIndexed { index, artistName -> val artistId = toUnifiedArtistId(artistName) artists.putIfAbsent( @@ -773,13 +798,14 @@ class NavidromeRepository @Inject constructor( AlbumEntity( id = albumId, title = albumName, - artistName = primaryArtistName, - artistId = primaryArtistId, + artistName = albumDisplayArtistName, + artistId = albumDisplayArtistId, songCount = 0, dateAdded = navidromeSong.dateAdded, year = navidromeSong.year, albumArtUriString = navidromeSong.coverArtId?.takeIf { it.isNotBlank() } - ?.let { "navidrome_cover://$it" } + ?.let { "navidrome_cover://$it" }, + albumArtist = navidromeSong.albumArtist?.takeIf { it.isNotBlank() } ) ) @@ -789,7 +815,8 @@ class NavidromeRepository @Inject constructor( title = navidromeSong.title, artistName = navidromeSong.artist.ifBlank { primaryArtistName }, artistId = primaryArtistId, - albumArtist = null, + albumArtist = navidromeSong.albumArtist?.takeIf { it.isNotBlank() }, + albumArtistId = albumArtistId, albumName = albumName, albumId = albumId, contentUriString = "navidrome://${navidromeSong.navidromeId}", diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt index 837af7a..cf5cf74 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt @@ -35,6 +35,7 @@ data class NavidromeSong( val title: String, val artist: String, val artistId: String? = null, + val albumArtist: String? = null, val album: String, val albumId: String? = null, val coverArt: String? = null, @@ -56,6 +57,7 @@ data class NavidromeSong( title = "", artist = "", artistId = null, + albumArtist = null, album = "", albumId = null, coverArt = null, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt index 89d16a4..2c6ad21 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt @@ -23,8 +23,9 @@ object JellyfinResponseParser { } } } + val albumArtist = json.optString("AlbumArtist").takeIf { it.isNotBlank() } val artist = artistNames.joinToString(", ").ifBlank { - json.optString("AlbumArtist", "Unknown Artist") + albumArtist ?: "Unknown Artist" } val artistIds = buildList { @@ -51,6 +52,7 @@ object JellyfinResponseParser { title = json.optString("Name", "Unknown Title"), artist = artist, artistId = artistIds.firstOrNull(), + albumArtist = albumArtist, album = json.optString("Album", "Unknown Album"), albumId = json.optString("AlbumId").takeIf { it.isNotBlank() }, duration = (json.optLong("RunTimeTicks", 0L) / 10_000), // Ticks to milliseconds diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt index abedd7b..567e6b4 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt @@ -88,6 +88,7 @@ object NavidromeResponseParser { title = json.optString("title", json.optString("name", "Unknown Title")), artist = json.optString("artist", "Unknown Artist"), artistId = json.optString("artistId").takeIf { it.isNotEmpty() }, + albumArtist = json.optString("albumArtist").takeIf { it.isNotEmpty() }, album = json.optString("album", "Unknown Album"), albumId = json.optString("albumId").takeIf { it.isNotEmpty() }, coverArt = json.optString("coverArt").takeIf { it.isNotEmpty() }, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt index d50364a..eb00b1d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt @@ -208,10 +208,11 @@ class MusicRepositoryImpl @Inject constructor( ): Flow> { return combine( userPreferencesRepository.allowedDirectoriesFlow, - userPreferencesRepository.blockedDirectoriesFlow - ) { allowedDirs, blockedDirs -> - allowedDirs to blockedDirs - }.flatMapLatest { (allowedDirs, blockedDirs) -> + userPreferencesRepository.blockedDirectoriesFlow, + userPreferencesRepository.groupByAlbumArtistFlow + ) { allowedDirs, blockedDirs, groupByAlbumArtist -> + Triple(allowedDirs, blockedDirs, groupByAlbumArtist) + }.flatMapLatest { (allowedDirs, blockedDirs, groupByAlbumArtist) -> flow { val (allowedParentDirs, applyDirectoryFilter) = computeAllowedDirs(allowedDirs, blockedDirs) @@ -219,12 +220,23 @@ class MusicRepositoryImpl @Inject constructor( Pager( config = defaultLibraryPagingConfig, pagingSourceFactory = { - musicDao.getArtistsPaginated( - allowedParentDirs = allowedParentDirs, - applyDirectoryFilter = applyDirectoryFilter, - filterMode = storageFilter.toFilterMode(), - sortOrder = sortOption.storageKey - ) + // "Group by Album Artist" collapses the tab onto each song's effective + // album artist; otherwise it lists every track-level artist as before. + if (groupByAlbumArtist) { + musicDao.getArtistsPaginatedByAlbumArtist( + allowedParentDirs = allowedParentDirs, + applyDirectoryFilter = applyDirectoryFilter, + filterMode = storageFilter.toFilterMode(), + sortOrder = sortOption.storageKey + ) + } else { + musicDao.getArtistsPaginated( + allowedParentDirs = allowedParentDirs, + applyDirectoryFilter = applyDirectoryFilter, + filterMode = storageFilter.toFilterMode(), + sortOrder = sortOption.storageKey + ) + } } ).flow ) @@ -454,8 +466,17 @@ class MusicRepositoryImpl @Inject constructor( .flowOn(Dispatchers.IO) } + @OptIn(ExperimentalCoroutinesApi::class) override fun getSongsForArtist(artistId: Long): Flow> { - return musicDao.getSongsForArtist(artistId).map { entities -> + return userPreferencesRepository.groupByAlbumArtistFlow.flatMapLatest { groupByAlbumArtist -> + // Mirror the Artists tab: in album-artist mode the detail shows the songs whose + // effective album artist is this id, keeping tab and detail consistent. + if (groupByAlbumArtist) { + musicDao.getSongsForArtistByAlbumArtist(artistId) + } else { + musicDao.getSongsForArtist(artistId) + } + }.map { entities -> entities.map { it.toSong() } }.flowOn(Dispatchers.IO) } 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 0117a5d..6c4fae9 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 @@ -519,6 +519,20 @@ constructor( ?: songArtistNameTrimmed val primaryArtistId = artistNameToId[primaryArtistName] ?: song.artistId + // Effective album artist = the album_artist tag when usable, else the primary track + // artist. Registered as a first-class artist row (even for compilation-only names like + // "Various Artists" that never appear as a track artist) so the "Group by Album Artist" + // tab can collapse onto songs.album_artist_id. Preference-independent: the toggle only + // chooses whether the tab/detail query reads this column, so no re-sync is needed. + val effectiveAlbumArtistName = song.albumArtist + ?.trim() + ?.takeIf { it.isNotEmpty() && !it.equals("", ignoreCase = true) } + ?: primaryArtistName + if (effectiveAlbumArtistName.isNotEmpty() && !artistNameToId.containsKey(effectiveAlbumArtistName)) { + artistNameToId[effectiveAlbumArtistName] = nextArtistId.getAndIncrement() + } + val albumArtistId = artistNameToId[effectiveAlbumArtistName] ?: primaryArtistId + allArtistsForSong.forEachIndexed { index, artistName -> val normalizedName = artistName.trim() val artistId = artistNameToId[normalizedName] @@ -550,6 +564,7 @@ constructor( song.copy( artistId = primaryArtistId, artistName = rawArtistName, // Preserving full artist string for display + albumArtistId = albumArtistId, albumId = finalAlbumId, artistsJson = serializeArtistRefs(artistRefsForJson) ) 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 c2de2f7..796f1d2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt @@ -22,6 +22,7 @@ import com.lostf1sh.pixelplayeross.data.database.EngagementDao import com.lostf1sh.pixelplayeross.data.database.FavoritesDao import com.lostf1sh.pixelplayeross.data.database.LyricsDao import com.lostf1sh.pixelplayeross.data.database.LocalPlaylistDao +import com.lostf1sh.pixelplayeross.data.database.MIGRATION_1_2 import com.lostf1sh.pixelplayeross.data.database.MusicDao import com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase import com.lostf1sh.pixelplayeross.data.database.SearchHistoryDao @@ -123,6 +124,7 @@ object AppModule { "pixelplayer_database" ) .addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback()) + .addMigrations(MIGRATION_1_2) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) // P2-4: Only allow destructive recreation in debug builds. diff --git a/app/src/main/res/values/strings_presentation_batch_g.xml b/app/src/main/res/values/strings_presentation_batch_g.xml index 6bbc797..4e2fcd6 100644 --- a/app/src/main/res/values/strings_presentation_batch_g.xml +++ b/app/src/main/res/values/strings_presentation_batch_g.xml @@ -175,7 +175,7 @@ Detect feat., ft., with in song titles Library Organization Group by Album Artist - Show collaboration albums under main artist + Group the Artists tab and albums by album artist, so featured and compilation artists don\'t clutter the list About Multi-Artist Parsing PixelPlayerOSS splits artist tags using character delimiters (/, ;, &) and word delimiters (feat., ft., vs., x). Word delimiters are matched case-insensitively. From cab5f3edb272ec00618daa4a1564653e26371011 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Thu, 18 Jun 2026 14:34:48 +0300 Subject: [PATCH 3/4] Make ScreenWrapper topIndex snapshot-aware and bump lifecycle to 2.11.0 The lifecycle 2.11.0 lint rule LifecycleCurrentStateInComposition flags reading Lifecycle.currentState during composition: it is not snapshot-backed, so topIndex could read stale state and fail to recompose when an entry's lifecycle state changed on its own. Read each visible entry's started-state via the snapshot-aware currentStateAsState() instead, keyed on entry id so each per-entry observer stays bound across recompositions. The started-state filter is kept (not redundant): visibleEntries is filtered by maxLifecycle, so a mid-transition incoming entry can appear while its real state is still CREATED. Dim-overlay behavior is unchanged. --- .../presentation/components/ScreenWrapper.kt | 17 +++++++++++++++-- gradle/libs.versions.toml | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ScreenWrapper.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ScreenWrapper.kt index e837521..29c6c71 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ScreenWrapper.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ScreenWrapper.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -67,8 +68,20 @@ fun ScreenWrapper( val visibleEntries by navController.visibleEntries.collectAsStateWithLifecycle() val myEntry = lifecycleOwner as? androidx.navigation.NavBackStackEntry val myIndex = visibleEntries.indexOfFirst { it.id == myEntry?.id } - val topIndex = visibleEntries.indexOfLast { - it.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + // topIndex is the topmost visible entry that has actually reached STARTED. visibleEntries + // is filtered by each entry's maxLifecycle (the ceiling the NavController targets), so a + // mid-transition incoming entry can appear here while its real lifecycle state is still + // CREATED — the started-state filter is what excludes it, and it is not redundant. Read + // that state snapshot-aware via currentStateAsState() (not the non-snapshot + // Lifecycle.currentState getter) so topIndex recomposes when an entry's state changes on + // its own, e.g. a transition completing without visibleEntries re-emitting. key(entry.id) + // keeps each per-entry lifecycle observer bound to a stable entry across recompositions. + var topIndex = -1 + for ((index, entry) in visibleEntries.withIndex()) { + val isStarted = key(entry.id) { + entry.lifecycle.currentStateAsState().value.isAtLeast(Lifecycle.State.STARTED) + } + if (isStarted) topIndex = index } // currentBackStackEntry updates synchronously with navigate()/popBackStack(), so it diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5111587..7147f80 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ espressoCore = "3.7.0" kotlinx-coroutines = "1.11.0" kotlinxCollectionsImmutable = "0.4.0" kotlinxSerializationJson = "1.11.0" -lifecycleRuntimeKtx = "2.10.0" +lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" composeBom = "2026.05.01" material = "1.14.0" From f17698ed53d3779ee1276138c64cae774756f079 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Thu, 18 Jun 2026 15:25:29 +0300 Subject: [PATCH 4/4] Bump core-ktx to 1.19.0 and stub Uri.parse in shuffle tests core-ktx 1.19.0 changes timing so beginPreparingSong's IO coroutine (albumArtUriString.toUri()) is awaited within the shuffle unit tests. Uri.parse returns null on the JVM stub, throwing NPE and regressing the six ShuffleFunctionalityTests. Stub android.net.Uri.parse in stubShuffledPlayback so the queue-prep path completes; assertions unchanged. --- .../presentation/viewmodel/PlayerViewModelTest.kt | 7 +++++++ gradle/libs.versions.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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 6d799e8..8a6862e 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 @@ -474,6 +474,13 @@ class PlayerViewModelTest { val mockedPlaybackUri = mockk(relaxed = true) every { mockedPlaybackUri.scheme } returns "file" every { MediaItemBuilder.playbackUri(any()) } returns mockedPlaybackUri + + // beginPreparingSong() runs on Dispatchers.IO and calls String.toUri() on the + // song's albumArtUriString. In JVM unit tests android.net.Uri.parse(...) returns + // null (android stub + isReturnDefaultValues), so toUri() throws NPE. Stub the + // static parse so the queue-prep path runs to completion under core-ktx 1.19.0. + mockkStatic(android.net.Uri::class) + every { android.net.Uri.parse(any()) } returns mockk(relaxed = true) } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7147f80..8fcd63c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ hiltAndroid = "2.59.2" hiltNavigationCompose = "1.3.0" ktor = "3.5.0" kotlin = "2.3.21" -coreKtx = "1.18.0" +coreKtx = "1.19.0" junit = "4.13.2" junitVersion = "1.3.0" junitJupiter = "6.1.0"