diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5062b8e..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) @@ -300,12 +301,13 @@ dependencies { androidTestImplementation(libs.androidx.junit) 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) + 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/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/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/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 e572c919..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 @@ -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,14 @@ 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.MIGRATION_5_6 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 +49,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 +66,34 @@ class MusicServiceWorkflowTest { android.Manifest.permission.POST_NOTIFICATIONS ) } - musicDao = EntryPointAccessors - .fromApplication(context, WorkflowTestEntryPoint::class.java) - .musicDao() + // 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, + MIGRATION_5_6, + ) + .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) + .fallbackToDestructiveMigration(dropAllTables = true) + .build() + musicDao = database.musicDao() - val wavFile = File(context.cacheDir, "workflow_test_tone.wav").apply { + 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 +110,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 +262,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 +360,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/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..098325ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ android:maxSdkVersion="32" /> + + + @@ -202,8 +208,9 @@ + android:exported="true" + android:grantUriPermissions="true" + tools:ignore="ExportedContentProvider" /> 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) @@ -234,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) } @@ -270,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/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/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, 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/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/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/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..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 @@ -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,94 @@ 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) + } +} + +internal fun Tag.applyCustomMetadataField(update: CustomMetadataFieldUpdate) { + val standardFieldKey = runCatching { + // 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) { + 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 +1411,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 +1419,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/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/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/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..2f7cda10 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/playlist/M3uSyncRepository.kt @@ -0,0 +1,771 @@ +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 = createTemporaryDocument(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 createTemporaryDocument(treeUri: Uri, displayName: String): M3uDocument { + val rootDocumentUri = DocumentsContract.buildDocumentUriUsingTree( + treeUri, + DocumentsContract.getTreeDocumentId(treeUri), + ) + val created = DocumentsContract.createDocument( + resolver, + rootDocumentUri, + // 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)) + } + + 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 { + 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/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/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/data/provider/SharedArtworkContentProvider.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/provider/SharedArtworkContentProvider.kt index 0b0b7482..79d3651f 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/provider/SharedArtworkContentProvider.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/provider/SharedArtworkContentProvider.kt @@ -3,10 +3,13 @@ package com.lostf1sh.pixelplayeross.data.provider import android.content.ContentProvider import android.content.ContentValues import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.content.res.AssetFileDescriptor import android.database.Cursor import android.graphics.Bitmap import android.net.Uri +import android.os.Binder import android.os.ParcelFileDescriptor import androidx.core.graphics.drawable.toBitmap import coil.imageLoader @@ -64,6 +67,7 @@ class SharedArtworkContentProvider : ContentProvider() { val appContext = context?.applicationContext ?: throw FileNotFoundException("Artwork provider is unavailable") + enforceArtworkReadAccess(uri, appContext) val cloudArtworkUri = parseCloudArtworkUri(uri.toString(), appContext.packageName) if (cloudArtworkUri != null) { return openCloudArtworkPipe(appContext, cloudArtworkUri) @@ -89,6 +93,33 @@ class SharedArtworkContentProvider : ContentProvider() { )?.takeIf { it.exists() && it.isFile && it.canRead() } } + /** + * Some vendor System UI implementations reject URI grants before attempting to open a + * non-exported provider. The provider is therefore exported for compatibility, while every + * file open still requires either our own UID or the explicit per-item grant issued by the + * media session. + */ + private fun enforceArtworkReadAccess(uri: Uri, appContext: Context) { + val callingUid = Binder.getCallingUid() + val permissionResult = appContext.checkUriPermission( + uri, + Binder.getCallingPid(), + callingUid, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + if ( + !hasArtworkReadAccess( + callingUid = callingUid, + providerUid = appContext.applicationInfo.uid, + uriPermissionResult = permissionResult, + ) + ) { + // FileNotFoundException is intentionally used here: media clients commonly treat a + // missing cover as optional, whereas vendor System UI code may crash on SecurityException. + throw FileNotFoundException("Artwork URI has not been granted to the caller") + } + } + private fun openCloudArtworkPipe( appContext: Context, rawArtworkUri: String, @@ -232,5 +263,14 @@ class SharedArtworkContentProvider : ContentProvider() { } return songIdSegment.toLongOrNull() } + + internal fun hasArtworkReadAccess( + callingUid: Int, + providerUid: Int, + uriPermissionResult: Int, + ): Boolean { + return callingUid == providerUid || + uriPermissionResult == PackageManager.PERMISSION_GRANTED + } } } 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/data/service/MusicService.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/MusicService.kt index 81cd299f..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 @@ -51,6 +51,7 @@ import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.data.repository.MusicRepository import com.lostf1sh.pixelplayeross.data.service.player.DualPlayerEngine import com.lostf1sh.pixelplayeross.data.service.player.TransitionController +import com.lostf1sh.pixelplayeross.data.service.player.selectCanonicalCloudPlaybackUri import com.lostf1sh.pixelplayeross.ui.glancewidget.ControlWidget4x2 import com.lostf1sh.pixelplayeross.ui.glancewidget.PixelPlayerGlanceWidget import com.lostf1sh.pixelplayeross.ui.glancewidget.PlayerActions @@ -208,6 +209,8 @@ class MusicService : MediaSessionService() { } private var playbackSnapshotPersistJob: Job? = null private var playbackSnapshotUnloadWriteJob: Job? = null + private val playbackSnapshotItemCache = + PlaybackSnapshotItemCache() 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 @@ -1145,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)) { @@ -1327,42 +1342,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 +1379,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 +2293,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/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/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/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/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/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/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/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/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/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/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/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/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/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/LibraryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt index 8892871f..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 @@ -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)) + } + } } } @@ -533,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 -> false - } - } + 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 { @@ -566,7 +653,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 +670,6 @@ fun LibraryScreen( showMultiSelectionSheet = false } - LibraryTabId.ARTISTS -> Unit } } @@ -612,9 +705,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 +869,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 +1013,7 @@ fun LibraryScreen( } AnimatedContent( - targetState = isSelectionMode || isPlaylistSelectionMode || isAlbumSelectionMode, + targetState = hasSelectionInCurrentTab, label = "ActionRowModeSwitch", transitionSpec = { (slideInHorizontally { -it } + fadeIn()) togetherWith @@ -948,25 +1041,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 +1379,11 @@ fun LibraryScreen( }, isRefreshing = isRefreshing, onRefresh = onRefresh, + isSelectionMode = isArtistSelectionMode, + selectedArtistIds = selectedArtistIds, + onArtistLongPress = onArtistLongPress, + onArtistSelectionToggle = onArtistSelectionToggle, + getSelectionIndex = getArtistSelectionIndex, storageFilter = playerUiState.currentStorageFilter ) } @@ -1373,10 +1485,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, @@ -1539,7 +1654,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, @@ -1553,7 +1668,8 @@ fun LibraryScreen( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, removeFromListTrigger = {}, @@ -1574,27 +1690,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 +1728,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 +1760,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 +1914,7 @@ fun LibraryScreen( replayGainAlbumGainDb = replayGainAlbumGainDb, coverArtUpdate = coverArtUpdate ) + clearResolvedCategorySelection() } ) } @@ -3053,68 +3157,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/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/PlaylistDetailScreen.kt index fda8d165..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 @@ -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 @@ -32,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 @@ -48,6 +48,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 +96,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 +127,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 +161,7 @@ fun PlaylistDetailScreen( onDeletePlayListClick: () -> Unit, playerViewModel: PlayerViewModel, playlistViewModel: PlaylistViewModel = hiltViewModel(), + cloudDownloadsViewModel: CloudDownloadsViewModel = hiltViewModel(), navController: NavController ) { val uiState by playlistViewModel.uiState.collectAsStateWithLifecycle() @@ -185,6 +190,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 +201,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) @@ -231,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() @@ -243,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) { @@ -376,7 +392,8 @@ fun PlaylistDetailScreen( playerViewModel.playSongs( localReorderableSongs, localReorderableSongs.first(), - currentPlaylist.name + currentPlaylist.name, + currentPlaylist.id, ) if (playerStableState.isShuffleEnabled) playerViewModel.toggleShuffle() } @@ -715,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 -> @@ -732,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, @@ -888,6 +906,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, @@ -1036,7 +1072,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, @@ -1050,7 +1086,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..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) { @@ -494,7 +512,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 +526,8 @@ fun SearchScreen( newDiscNumber, replayGainTrackGainDb, replayGainAlbumGainDb, - coverArtUpdate + coverArtUpdate, + customMetadataChanges ) }, ) 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..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), @@ -582,6 +669,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), @@ -761,6 +856,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/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/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/settings/search/SettingsRegistry.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt index ca09e594..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", @@ -186,6 +206,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", @@ -337,6 +373,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/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/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/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/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 5e0fe5f3..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 @@ -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 @@ -75,6 +76,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 @@ -119,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 @@ -209,7 +213,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( @@ -579,13 +584,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 +619,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 +636,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, @@ -2985,7 +2995,7 @@ class PlayerViewModel @Inject constructor( } _isSheetVisible.value = true - val startMediaItem = buildResolvedPlaybackMediaItem(effectiveStartSong) + val startMediaItem = buildResolvedPlaybackMediaItem(effectiveStartSong, playlistId) val playSongsAction = { dualPlayerEngine.cancelNext() @@ -3023,23 +3033,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) } @@ -3451,6 +3450,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 } @@ -3788,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() } @@ -4284,6 +4332,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") @@ -4306,7 +4355,8 @@ class PlayerViewModel @Inject constructor( discNumber = newDiscNumber, replayGainTrackGainDb = newReplayGainTrackGainDb, replayGainAlbumGainDb = newReplayGainAlbumGainDb, - coverArtUpdate = coverArtUpdate + coverArtUpdate = coverArtUpdate, + customMetadataChanges = customMetadataChanges ) _writePermissionRequest.emit(intentSender) return@launch @@ -4314,7 +4364,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) } } @@ -4388,7 +4439,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 ) } } @@ -4452,6 +4504,7 @@ class PlayerViewModel @Inject constructor( newReplayGainTrackGainDb: String?, newReplayGainAlbumGainDb: String?, coverArtUpdate: CoverArtUpdate?, + customMetadataChanges: CustomMetadataChanges = CustomMetadataChanges(), ) { val previousAlbumArt = song.albumArtUriString @@ -4468,7 +4521,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/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt index 3e7647d2..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, @@ -68,6 +69,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, @@ -145,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( @@ -261,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, @@ -276,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 -> @@ -293,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 ) } } @@ -374,6 +380,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) } @@ -513,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) @@ -606,6 +624,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/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/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.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-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-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml index afb269a3..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 @@ -75,6 +83,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 @@ -141,6 +151,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.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/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/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 3dd67124..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 @@ -84,6 +92,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 @@ -137,6 +147,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/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/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/media/CustomMetadataTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadataTest.kt new file mode 100644 index 00000000..2d85754a --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/media/CustomMetadataTest.kt @@ -0,0 +1,161 @@ +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 +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)) + 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" }) + } +} 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/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) } 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/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/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/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/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/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/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") + } +} 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 + ) + } } 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) 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( 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/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() + } +} 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) + } +} 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( 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() + } +} 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() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad3565c6..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" } @@ -170,13 +172,16 @@ 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" } +snakeyaml = { group = "org.yaml", name = "snakeyaml", version.ref = "snakeYaml" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }