diff --git a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/44.json b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/1.json similarity index 98% rename from app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/44.json rename to app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/1.json index d119d90..3eba522 100644 --- a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/44.json +++ b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/1.json @@ -1,12 +1,12 @@ { "formatVersion": 1, "database": { - "version": 44, - "identityHash": "64e27ca265af41c69f0cbe917ea25b4d", + "version": 1, + "identityHash": "c8f25afd12794dbf0e8312cd1587092f", "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`))", + "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`, `paletteStyle`))", "fields": [ { "fieldPath": "albumArtUriString", @@ -600,21 +600,10 @@ "primaryKey": { "autoGenerate": false, "columnNames": [ - "albumArtUriString" + "albumArtUriString", + "paletteStyle" ] - }, - "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", @@ -2004,7 +1993,7 @@ ], "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, '64e27ca265af41c69f0cbe917ea25b4d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c8f25afd12794dbf0e8312cd1587092f')" ] } } \ No newline at end of file diff --git a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/43.json b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/43.json deleted file mode 100644 index fcf10dd..0000000 --- a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/43.json +++ /dev/null @@ -1,1968 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 43, - "identityHash": "7159129746ad51f1b9c7ce7b814e1b92", - "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_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, 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": "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" - } - ], - "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_parent_directory_path_source_type_album_id", - "unique": false, - "columnNames": [ - "parent_directory_path", - "source_type", - "album_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_album_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `album_id`)" - }, - { - "name": "index_songs_parent_directory_path_source_type_id", - "unique": false, - "columnNames": [ - "parent_directory_path", - "source_type", - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `id`)" - } - ], - "foreignKeys": [ - { - "table": "albums", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "album_id" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "artists", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "artist_id" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "songs_fts", - "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, tokenize=unicode61)", - "fields": [ - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artistName", - "columnName": "artist_name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowid" - ] - }, - "ftsVersion": "FTS4", - "ftsOptions": { - "tokenizer": "unicode61", - "tokenizerArgs": [], - "contentTable": "", - "languageIdColumnName": "", - "matchInfo": "FTS4", - "notIndexedColumns": [], - "prefixSizes": [], - "preferredOrder": "ASC" - }, - "contentSyncTriggers": [] - }, - { - "tableName": "albums", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_art_uri_string` TEXT, `song_count` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `year` INTEGER NOT NULL, `album_artist` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artistName", - "columnName": "artist_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artistId", - "columnName": "artist_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumArtUriString", - "columnName": "album_art_uri_string", - "affinity": "TEXT" - }, - { - "fieldPath": "songCount", - "columnName": "song_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dateAdded", - "columnName": "date_added", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumArtist", - "columnName": "album_artist", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_albums_title", - "unique": false, - "columnNames": [ - "title" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_title` ON `${TABLE_NAME}` (`title`)" - }, - { - "name": "index_albums_artist_id", - "unique": false, - "columnNames": [ - "artist_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_id` ON `${TABLE_NAME}` (`artist_id`)" - }, - { - "name": "index_albums_artist_name", - "unique": false, - "columnNames": [ - "artist_name" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_name` ON `${TABLE_NAME}` (`artist_name`)" - }, - { - "name": "index_albums_album_artist", - "unique": false, - "columnNames": [ - "album_artist" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_album_artist` ON `${TABLE_NAME}` (`album_artist`)" - } - ] - }, - { - "tableName": "artists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `track_count` INTEGER NOT NULL, `image_url` TEXT, `custom_image_uri` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "trackCount", - "columnName": "track_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageUrl", - "columnName": "image_url", - "affinity": "TEXT" - }, - { - "fieldPath": "customImageUri", - "columnName": "custom_image_uri", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_artists_name", - "unique": false, - "columnNames": [ - "name" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`name`)" - } - ] - }, - { - "tableName": "transition_rules", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `fromTrackId` TEXT, `toTrackId` TEXT, `mode` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `curveIn` TEXT NOT NULL, `curveOut` TEXT NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playlistId", - "columnName": "playlistId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "fromTrackId", - "columnName": "fromTrackId", - "affinity": "TEXT" - }, - { - "fieldPath": "toTrackId", - "columnName": "toTrackId", - "affinity": "TEXT" - }, - { - "fieldPath": "settings.mode", - "columnName": "mode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "settings.durationMs", - "columnName": "durationMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "settings.curveIn", - "columnName": "curveIn", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "settings.curveOut", - "columnName": "curveOut", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_transition_rules_playlistId_fromTrackId_toTrackId", - "unique": true, - "columnNames": [ - "playlistId", - "fromTrackId", - "toTrackId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_transition_rules_playlistId_fromTrackId_toTrackId` ON `${TABLE_NAME}` (`playlistId`, `fromTrackId`, `toTrackId`)" - } - ] - }, - { - "tableName": "song_artist_cross_ref", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `is_primary` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`song_id`, `artist_id`), FOREIGN KEY(`song_id`) REFERENCES `songs`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "songId", - "columnName": "song_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "artistId", - "columnName": "artist_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPrimary", - "columnName": "is_primary", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "song_id", - "artist_id" - ] - }, - "indices": [ - { - "name": "index_song_artist_cross_ref_song_id", - "unique": false, - "columnNames": [ - "song_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_song_id` ON `${TABLE_NAME}` (`song_id`)" - }, - { - "name": "index_song_artist_cross_ref_artist_id", - "unique": false, - "columnNames": [ - "artist_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_artist_id` ON `${TABLE_NAME}` (`artist_id`)" - }, - { - "name": "index_song_artist_cross_ref_is_primary", - "unique": false, - "columnNames": [ - "is_primary" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_is_primary` ON `${TABLE_NAME}` (`is_primary`)" - } - ], - "foreignKeys": [ - { - "table": "songs", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "song_id" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "artists", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "artist_id" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "song_engagements", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `play_count` INTEGER NOT NULL, `total_play_duration_ms` INTEGER NOT NULL, `last_played_timestamp` INTEGER NOT NULL, PRIMARY KEY(`song_id`))", - "fields": [ - { - "fieldPath": "songId", - "columnName": "song_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "playCount", - "columnName": "play_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "totalPlayDurationMs", - "columnName": "total_play_duration_ms", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastPlayedTimestamp", - "columnName": "last_played_timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "song_id" - ] - }, - "indices": [ - { - "name": "index_song_engagements_play_count", - "unique": false, - "columnNames": [ - "play_count" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_engagements_play_count` ON `${TABLE_NAME}` (`play_count`)" - } - ] - }, - { - "tableName": "favorites", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`songId`))", - "fields": [ - { - "fieldPath": "songId", - "columnName": "songId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isFavorite", - "columnName": "isFavorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "songId" - ] - }, - "indices": [ - { - "name": "index_favorites_timestamp", - "unique": false, - "columnNames": [ - "timestamp" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_timestamp` ON `${TABLE_NAME}` (`timestamp`)" - } - ] - }, - { - "tableName": "lyrics", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `content` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `source` TEXT, PRIMARY KEY(`songId`))", - "fields": [ - { - "fieldPath": "songId", - "columnName": "songId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "content", - "columnName": "content", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isSynced", - "columnName": "isSynced", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "source", - "columnName": "source", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "songId" - ] - } - }, - { - "tableName": "playlists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `is_queue_generated` INTEGER NOT NULL, `cover_image_uri` TEXT, `cover_color_argb` INTEGER, `cover_icon_name` TEXT, `cover_shape_type` TEXT, `cover_shape_detail_1` REAL, `cover_shape_detail_2` REAL, `cover_shape_detail_3` REAL, `cover_shape_detail_4` REAL, `source` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "created_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "last_modified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isQueueGenerated", - "columnName": "is_queue_generated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "coverImageUri", - "columnName": "cover_image_uri", - "affinity": "TEXT" - }, - { - "fieldPath": "coverColorArgb", - "columnName": "cover_color_argb", - "affinity": "INTEGER" - }, - { - "fieldPath": "coverIconName", - "columnName": "cover_icon_name", - "affinity": "TEXT" - }, - { - "fieldPath": "coverShapeType", - "columnName": "cover_shape_type", - "affinity": "TEXT" - }, - { - "fieldPath": "coverShapeDetail1", - "columnName": "cover_shape_detail_1", - "affinity": "REAL" - }, - { - "fieldPath": "coverShapeDetail2", - "columnName": "cover_shape_detail_2", - "affinity": "REAL" - }, - { - "fieldPath": "coverShapeDetail3", - "columnName": "cover_shape_detail_3", - "affinity": "REAL" - }, - { - "fieldPath": "coverShapeDetail4", - "columnName": "cover_shape_detail_4", - "affinity": "REAL" - }, - { - "fieldPath": "source", - "columnName": "source", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_playlists_last_modified", - "unique": false, - "columnNames": [ - "last_modified" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_last_modified` ON `${TABLE_NAME}` (`last_modified`)" - } - ] - }, - { - "tableName": "playlist_songs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `song_id` TEXT NOT NULL, `sort_order` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `song_id`))", - "fields": [ - { - "fieldPath": "playlistId", - "columnName": "playlist_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "songId", - "columnName": "song_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sortOrder", - "columnName": "sort_order", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "playlist_id", - "song_id" - ] - }, - "indices": [ - { - "name": "index_playlist_songs_playlist_id_sort_order", - "unique": false, - "columnNames": [ - "playlist_id", - "sort_order" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_playlist_id_sort_order` ON `${TABLE_NAME}` (`playlist_id`, `sort_order`)" - }, - { - "name": "index_playlist_songs_song_id", - "unique": false, - "columnNames": [ - "song_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_song_id` ON `${TABLE_NAME}` (`song_id`)" - } - ] - }, - { - "tableName": "navidrome_songs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `navidrome_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album` 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": "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` 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": "album", - "columnName": "album", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "album_id", - "affinity": "TEXT" - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trackNumber", - "columnName": "track_number", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "discNumber", - "columnName": "disc_number", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "genre", - "columnName": "genre", - "affinity": "TEXT" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - }, - { - "fieldPath": "mimeType", - "columnName": "mime_type", - "affinity": "TEXT" - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "dateAdded", - "columnName": "date_added", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_jellyfin_songs_jellyfin_id", - "unique": false, - "columnNames": [ - "jellyfin_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_jellyfin_id` ON `${TABLE_NAME}` (`jellyfin_id`)" - }, - { - "name": "index_jellyfin_songs_playlist_id", - "unique": false, - "columnNames": [ - "playlist_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" - }, - { - "name": "index_jellyfin_songs_playlist_id_date_added", - "unique": false, - "columnNames": [ - "playlist_id", - "date_added" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)" - } - ] - }, - { - "tableName": "jellyfin_playlists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "songCount", - "columnName": "song_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastSyncTime", - "columnName": "last_sync_time", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7159129746ad51f1b9c7ce7b814e1b92')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/45.json b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/45.json deleted file mode 100644 index dab156b..0000000 --- a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/45.json +++ /dev/null @@ -1,2010 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 45, - "identityHash": "64e27ca265af41c69f0cbe917ea25b4d", - "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_name` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `content_uri_string` TEXT NOT NULL, `album_art_uri_string` TEXT, `duration` INTEGER NOT NULL, `genre` TEXT, `file_path` TEXT NOT NULL, `parent_directory_path` TEXT NOT NULL, `is_favorite` INTEGER NOT NULL DEFAULT 0, `lyrics` TEXT DEFAULT null, `track_number` INTEGER NOT NULL DEFAULT 0, `disc_number` INTEGER DEFAULT null, `year` INTEGER NOT NULL DEFAULT 0, `date_added` INTEGER NOT NULL DEFAULT 0, `mime_type` TEXT, `bitrate` INTEGER, `sample_rate` INTEGER, `artists_json` TEXT, `source_type` INTEGER NOT NULL DEFAULT 0, `media_store_date_added` INTEGER NOT NULL DEFAULT 0, `media_store_date_modified` INTEGER NOT NULL DEFAULT 0, `title_user_edited` INTEGER NOT NULL DEFAULT 0, `artist_user_edited` INTEGER NOT NULL DEFAULT 0, `album_user_edited` INTEGER NOT NULL DEFAULT 0, `genre_user_edited` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`album_id`) REFERENCES `albums`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artistName", - "columnName": "artist_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artistId", - "columnName": "artist_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumArtist", - "columnName": "album_artist", - "affinity": "TEXT" - }, - { - "fieldPath": "albumName", - "columnName": "album_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "album_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "contentUriString", - "columnName": "content_uri_string", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumArtUriString", - "columnName": "album_art_uri_string", - "affinity": "TEXT" - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "genre", - "columnName": "genre", - "affinity": "TEXT" - }, - { - "fieldPath": "filePath", - "columnName": "file_path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentDirectoryPath", - "columnName": "parent_directory_path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isFavorite", - "columnName": "is_favorite", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "lyrics", - "columnName": "lyrics", - "affinity": "TEXT", - "defaultValue": "null" - }, - { - "fieldPath": "trackNumber", - "columnName": "track_number", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "discNumber", - "columnName": "disc_number", - "affinity": "INTEGER", - "defaultValue": "null" - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "dateAdded", - "columnName": "date_added", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "mimeType", - "columnName": "mime_type", - "affinity": "TEXT" - }, - { - "fieldPath": "bitrate", - "columnName": "bitrate", - "affinity": "INTEGER" - }, - { - "fieldPath": "sampleRate", - "columnName": "sample_rate", - "affinity": "INTEGER" - }, - { - "fieldPath": "artistsJson", - "columnName": "artists_json", - "affinity": "TEXT" - }, - { - "fieldPath": "sourceType", - "columnName": "source_type", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "mediaStoreDateAdded", - "columnName": "media_store_date_added", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "mediaStoreDateModified", - "columnName": "media_store_date_modified", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "titleUserEdited", - "columnName": "title_user_edited", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "artistUserEdited", - "columnName": "artist_user_edited", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "albumUserEdited", - "columnName": "album_user_edited", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "genreUserEdited", - "columnName": "genre_user_edited", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_songs_title", - "unique": false, - "columnNames": [ - "title" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_title` ON `${TABLE_NAME}` (`title`)" - }, - { - "name": "index_songs_album_id", - "unique": false, - "columnNames": [ - "album_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_album_id` ON `${TABLE_NAME}` (`album_id`)" - }, - { - "name": "index_songs_artist_id", - "unique": false, - "columnNames": [ - "artist_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_id` ON `${TABLE_NAME}` (`artist_id`)" - }, - { - "name": "index_songs_artist_name", - "unique": false, - "columnNames": [ - "artist_name" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_name` ON `${TABLE_NAME}` (`artist_name`)" - }, - { - "name": "index_songs_genre", - "unique": false, - "columnNames": [ - "genre" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_genre` ON `${TABLE_NAME}` (`genre`)" - }, - { - "name": "index_songs_parent_directory_path", - "unique": false, - "columnNames": [ - "parent_directory_path" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path` ON `${TABLE_NAME}` (`parent_directory_path`)" - }, - { - "name": "index_songs_file_path", - "unique": false, - "columnNames": [ - "file_path" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_file_path` ON `${TABLE_NAME}` (`file_path`)" - }, - { - "name": "index_songs_content_uri_string", - "unique": false, - "columnNames": [ - "content_uri_string" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_content_uri_string` ON `${TABLE_NAME}` (`content_uri_string`)" - }, - { - "name": "index_songs_date_added", - "unique": false, - "columnNames": [ - "date_added" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_date_added` ON `${TABLE_NAME}` (`date_added`)" - }, - { - "name": "index_songs_duration", - "unique": false, - "columnNames": [ - "duration" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_duration` ON `${TABLE_NAME}` (`duration`)" - }, - { - "name": "index_songs_source_type", - "unique": false, - "columnNames": [ - "source_type" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_source_type` ON `${TABLE_NAME}` (`source_type`)" - }, - { - "name": "index_songs_parent_directory_path_source_type_album_id", - "unique": false, - "columnNames": [ - "parent_directory_path", - "source_type", - "album_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_album_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `album_id`)" - }, - { - "name": "index_songs_parent_directory_path_source_type_id", - "unique": false, - "columnNames": [ - "parent_directory_path", - "source_type", - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `id`)" - } - ], - "foreignKeys": [ - { - "table": "albums", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "album_id" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "artists", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "artist_id" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "songs_fts", - "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, tokenize=unicode61)", - "fields": [ - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artistName", - "columnName": "artist_name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowid" - ] - }, - "ftsVersion": "FTS4", - "ftsOptions": { - "tokenizer": "unicode61", - "tokenizerArgs": [], - "contentTable": "", - "languageIdColumnName": "", - "matchInfo": "FTS4", - "notIndexedColumns": [], - "prefixSizes": [], - "preferredOrder": "ASC" - }, - "contentSyncTriggers": [] - }, - { - "tableName": "albums", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_art_uri_string` TEXT, `song_count` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `year` INTEGER NOT NULL, `album_artist` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artistName", - "columnName": "artist_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artistId", - "columnName": "artist_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumArtUriString", - "columnName": "album_art_uri_string", - "affinity": "TEXT" - }, - { - "fieldPath": "songCount", - "columnName": "song_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dateAdded", - "columnName": "date_added", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "albumArtist", - "columnName": "album_artist", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_albums_title", - "unique": false, - "columnNames": [ - "title" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_title` ON `${TABLE_NAME}` (`title`)" - }, - { - "name": "index_albums_artist_id", - "unique": false, - "columnNames": [ - "artist_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_id` ON `${TABLE_NAME}` (`artist_id`)" - }, - { - "name": "index_albums_artist_name", - "unique": false, - "columnNames": [ - "artist_name" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_name` ON `${TABLE_NAME}` (`artist_name`)" - }, - { - "name": "index_albums_album_artist", - "unique": false, - "columnNames": [ - "album_artist" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_album_artist` ON `${TABLE_NAME}` (`album_artist`)" - } - ] - }, - { - "tableName": "artists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `track_count` INTEGER NOT NULL, `image_url` TEXT, `custom_image_uri` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "trackCount", - "columnName": "track_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "imageUrl", - "columnName": "image_url", - "affinity": "TEXT" - }, - { - "fieldPath": "customImageUri", - "columnName": "custom_image_uri", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_artists_name", - "unique": false, - "columnNames": [ - "name" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`name`)" - } - ] - }, - { - "tableName": "transition_rules", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `fromTrackId` TEXT, `toTrackId` TEXT, `mode` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `curveIn` TEXT NOT NULL, `curveOut` TEXT NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "playlistId", - "columnName": "playlistId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "fromTrackId", - "columnName": "fromTrackId", - "affinity": "TEXT" - }, - { - "fieldPath": "toTrackId", - "columnName": "toTrackId", - "affinity": "TEXT" - }, - { - "fieldPath": "settings.mode", - "columnName": "mode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "settings.durationMs", - "columnName": "durationMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "settings.curveIn", - "columnName": "curveIn", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "settings.curveOut", - "columnName": "curveOut", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_transition_rules_playlistId_fromTrackId_toTrackId", - "unique": true, - "columnNames": [ - "playlistId", - "fromTrackId", - "toTrackId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_transition_rules_playlistId_fromTrackId_toTrackId` ON `${TABLE_NAME}` (`playlistId`, `fromTrackId`, `toTrackId`)" - } - ] - }, - { - "tableName": "song_artist_cross_ref", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `is_primary` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`song_id`, `artist_id`), FOREIGN KEY(`song_id`) REFERENCES `songs`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "songId", - "columnName": "song_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "artistId", - "columnName": "artist_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPrimary", - "columnName": "is_primary", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "song_id", - "artist_id" - ] - }, - "indices": [ - { - "name": "index_song_artist_cross_ref_song_id", - "unique": false, - "columnNames": [ - "song_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_song_id` ON `${TABLE_NAME}` (`song_id`)" - }, - { - "name": "index_song_artist_cross_ref_artist_id", - "unique": false, - "columnNames": [ - "artist_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_artist_id` ON `${TABLE_NAME}` (`artist_id`)" - }, - { - "name": "index_song_artist_cross_ref_is_primary", - "unique": false, - "columnNames": [ - "is_primary" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_is_primary` ON `${TABLE_NAME}` (`is_primary`)" - } - ], - "foreignKeys": [ - { - "table": "songs", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "song_id" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "artists", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "artist_id" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "song_engagements", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `play_count` INTEGER NOT NULL, `total_play_duration_ms` INTEGER NOT NULL, `last_played_timestamp` INTEGER NOT NULL, PRIMARY KEY(`song_id`))", - "fields": [ - { - "fieldPath": "songId", - "columnName": "song_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "playCount", - "columnName": "play_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "totalPlayDurationMs", - "columnName": "total_play_duration_ms", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastPlayedTimestamp", - "columnName": "last_played_timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "song_id" - ] - }, - "indices": [ - { - "name": "index_song_engagements_play_count", - "unique": false, - "columnNames": [ - "play_count" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_engagements_play_count` ON `${TABLE_NAME}` (`play_count`)" - } - ] - }, - { - "tableName": "favorites", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`songId`))", - "fields": [ - { - "fieldPath": "songId", - "columnName": "songId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isFavorite", - "columnName": "isFavorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "songId" - ] - }, - "indices": [ - { - "name": "index_favorites_timestamp", - "unique": false, - "columnNames": [ - "timestamp" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_timestamp` ON `${TABLE_NAME}` (`timestamp`)" - } - ] - }, - { - "tableName": "lyrics", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `content` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `source` TEXT, PRIMARY KEY(`songId`))", - "fields": [ - { - "fieldPath": "songId", - "columnName": "songId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "content", - "columnName": "content", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isSynced", - "columnName": "isSynced", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "source", - "columnName": "source", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "songId" - ] - } - }, - { - "tableName": "playlists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `is_queue_generated` INTEGER NOT NULL, `cover_image_uri` TEXT, `cover_color_argb` INTEGER, `cover_icon_name` TEXT, `cover_shape_type` TEXT, `cover_shape_detail_1` REAL, `cover_shape_detail_2` REAL, `cover_shape_detail_3` REAL, `cover_shape_detail_4` REAL, `source` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "created_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "last_modified", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isQueueGenerated", - "columnName": "is_queue_generated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "coverImageUri", - "columnName": "cover_image_uri", - "affinity": "TEXT" - }, - { - "fieldPath": "coverColorArgb", - "columnName": "cover_color_argb", - "affinity": "INTEGER" - }, - { - "fieldPath": "coverIconName", - "columnName": "cover_icon_name", - "affinity": "TEXT" - }, - { - "fieldPath": "coverShapeType", - "columnName": "cover_shape_type", - "affinity": "TEXT" - }, - { - "fieldPath": "coverShapeDetail1", - "columnName": "cover_shape_detail_1", - "affinity": "REAL" - }, - { - "fieldPath": "coverShapeDetail2", - "columnName": "cover_shape_detail_2", - "affinity": "REAL" - }, - { - "fieldPath": "coverShapeDetail3", - "columnName": "cover_shape_detail_3", - "affinity": "REAL" - }, - { - "fieldPath": "coverShapeDetail4", - "columnName": "cover_shape_detail_4", - "affinity": "REAL" - }, - { - "fieldPath": "source", - "columnName": "source", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_playlists_last_modified", - "unique": false, - "columnNames": [ - "last_modified" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_last_modified` ON `${TABLE_NAME}` (`last_modified`)" - } - ] - }, - { - "tableName": "playlist_songs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `song_id` TEXT NOT NULL, `sort_order` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `song_id`))", - "fields": [ - { - "fieldPath": "playlistId", - "columnName": "playlist_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "songId", - "columnName": "song_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sortOrder", - "columnName": "sort_order", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "playlist_id", - "song_id" - ] - }, - "indices": [ - { - "name": "index_playlist_songs_playlist_id_sort_order", - "unique": false, - "columnNames": [ - "playlist_id", - "sort_order" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_playlist_id_sort_order` ON `${TABLE_NAME}` (`playlist_id`, `sort_order`)" - }, - { - "name": "index_playlist_songs_song_id", - "unique": false, - "columnNames": [ - "song_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_song_id` ON `${TABLE_NAME}` (`song_id`)" - } - ] - }, - { - "tableName": "navidrome_songs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `navidrome_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album` 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": "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` 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": "album", - "columnName": "album", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "albumId", - "columnName": "album_id", - "affinity": "TEXT" - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "trackNumber", - "columnName": "track_number", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "discNumber", - "columnName": "disc_number", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "year", - "columnName": "year", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "genre", - "columnName": "genre", - "affinity": "TEXT" - }, - { - "fieldPath": "bitRate", - "columnName": "bitRate", - "affinity": "INTEGER" - }, - { - "fieldPath": "mimeType", - "columnName": "mime_type", - "affinity": "TEXT" - }, - { - "fieldPath": "path", - "columnName": "path", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "dateAdded", - "columnName": "date_added", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_jellyfin_songs_jellyfin_id", - "unique": false, - "columnNames": [ - "jellyfin_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_jellyfin_id` ON `${TABLE_NAME}` (`jellyfin_id`)" - }, - { - "name": "index_jellyfin_songs_playlist_id", - "unique": false, - "columnNames": [ - "playlist_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" - }, - { - "name": "index_jellyfin_songs_playlist_id_date_added", - "unique": false, - "columnNames": [ - "playlist_id", - "date_added" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)" - } - ] - }, - { - "tableName": "jellyfin_playlists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "songCount", - "columnName": "song_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "duration", - "columnName": "duration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastSyncTime", - "columnName": "last_sync_time", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '64e27ca265af41c69f0cbe917ea25b4d')" - ] - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabaseMigrationTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabaseMigrationTest.kt deleted file mode 100644 index 650daf6..0000000 --- a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabaseMigrationTest.kt +++ /dev/null @@ -1,304 +0,0 @@ -package com.lostf1sh.pixelplayeross.data.database - -import androidx.sqlite.db.SupportSQLiteDatabase -import androidx.sqlite.db.SupportSQLiteOpenHelper -import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class PixelPlayerDatabaseMigrationTest { - - @After - fun tearDown() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - context.deleteDatabase(DB_NAME_23_TO_24_DRIFTED) - context.deleteDatabase(DB_NAME_44_TO_45_NAVIDROME_DISC_NUMBER) - } - - @Test - fun migration23To24RepairsSongsWithoutDateAddedBeforeCreatingIndexes() { - val openHelper = createDriftedVersion23Database(DB_NAME_23_TO_24_DRIFTED) - val db = openHelper.writableDatabase - - try { - PixelPlayerDatabase.MIGRATION_23_24.migrate(db) - - val columns = db.tableColumns("songs") - assertTrue("date_added" in columns) - - db.query("SELECT date_added FROM songs WHERE id = 1").use { cursor -> - assertTrue(cursor.moveToFirst()) - assertEquals(0L, cursor.getLong(0)) - } - - db.query( - "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'index_songs_date_added'" - ).use { cursor -> - assertTrue(cursor.moveToFirst()) - assertEquals("index_songs_date_added", cursor.getString(0)) - } - } finally { - db.close() - openHelper.close() - } - } - - @Test - fun migration44To45MakesNavidromeDiscNumberNonNull() { - val openHelper = createVersion44DatabaseWithNullableNavidromeDiscNumber( - DB_NAME_44_TO_45_NAVIDROME_DISC_NUMBER - ) - val db = openHelper.writableDatabase - - try { - PixelPlayerDatabase.MIGRATION_44_45.migrate(db) - - assertTrue(db.isColumnNotNull("navidrome_songs", "disc_number")) - db.query("SELECT disc_number FROM navidrome_songs WHERE id = 'song-1'").use { cursor -> - assertTrue(cursor.moveToFirst()) - assertEquals(0L, cursor.getLong(0)) - } - } finally { - db.close() - openHelper.close() - } - } - - private fun createDriftedVersion23Database( - databaseName: String - ): SupportSQLiteOpenHelper { - val context = InstrumentationRegistry.getInstrumentation().targetContext - context.deleteDatabase(databaseName) - - val callback = object : SupportSQLiteOpenHelper.Callback(23) { - override fun onCreate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS songs ( - id INTEGER NOT NULL PRIMARY KEY, - title TEXT NOT NULL, - artist_name TEXT NOT NULL, - artist_id INTEGER NOT NULL, - album_artist TEXT, - 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, - year INTEGER NOT NULL DEFAULT 0, - mime_type TEXT, - bitrate INTEGER, - sample_rate INTEGER - ) - """.trimIndent() - ) - - db.execSQL( - """ - INSERT INTO songs ( - id, - title, - artist_name, - artist_id, - album_artist, - album_name, - album_id, - content_uri_string, - album_art_uri_string, - duration, - genre, - file_path, - parent_directory_path, - is_favorite, - lyrics, - track_number, - year, - mime_type, - bitrate, - sample_rate - ) VALUES ( - 1, - 'Song', - 'Artist', - 10, - NULL, - 'Album', - 20, - 'content://song/1', - NULL, - 180000, - NULL, - '/music/song.mp3', - '/music', - 0, - NULL, - 1, - 2024, - 'audio/mpeg', - 320000, - 44100 - ) - """.trimIndent() - ) - - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS favorites ( - songId INTEGER NOT NULL PRIMARY KEY, - isFavorite INTEGER NOT NULL, - timestamp INTEGER NOT NULL - ) - """.trimIndent() - ) - - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS song_engagements ( - song_id TEXT NOT NULL PRIMARY KEY, - play_count INTEGER NOT NULL DEFAULT 0, - total_play_duration_ms INTEGER NOT NULL DEFAULT 0, - last_played_timestamp INTEGER NOT NULL DEFAULT 0 - ) - """.trimIndent() - ) - } - - override fun onUpgrade( - db: SupportSQLiteDatabase, - oldVersion: Int, - newVersion: Int - ) = Unit - } - - return FrameworkSQLiteOpenHelperFactory().create( - SupportSQLiteOpenHelper.Configuration.builder(context) - .name(databaseName) - .callback(callback) - .build() - ) - } - - private fun createVersion44DatabaseWithNullableNavidromeDiscNumber( - databaseName: String - ): SupportSQLiteOpenHelper { - val context = InstrumentationRegistry.getInstrumentation().targetContext - context.deleteDatabase(databaseName) - - val callback = object : SupportSQLiteOpenHelper.Callback(44) { - override fun onCreate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE navidrome_songs ( - id TEXT NOT NULL PRIMARY KEY, - navidrome_id TEXT NOT NULL, - playlist_id TEXT NOT NULL, - title TEXT NOT NULL, - artist TEXT NOT NULL, - artist_id TEXT, - album TEXT NOT NULL, - album_id TEXT, - cover_art_id TEXT, - duration INTEGER NOT NULL, - track_number INTEGER NOT NULL, - disc_number INTEGER, - year INTEGER NOT NULL, - genre TEXT, - bitRate INTEGER, - mime_type TEXT, - suffix TEXT, - path TEXT NOT NULL, - date_added INTEGER NOT NULL - ) - """.trimIndent() - ) - - db.execSQL( - """ - INSERT INTO navidrome_songs ( - id, - navidrome_id, - playlist_id, - title, - artist, - album, - duration, - track_number, - disc_number, - year, - path, - date_added - ) VALUES ( - 'song-1', - 'navidrome-1', - 'playlist-1', - 'Song', - 'Artist', - 'Album', - 180000, - 1, - NULL, - 2024, - '/music/song.mp3', - 123456 - ) - """.trimIndent() - ) - } - - override fun onUpgrade( - db: SupportSQLiteDatabase, - oldVersion: Int, - newVersion: Int - ) = Unit - } - - return FrameworkSQLiteOpenHelperFactory().create( - SupportSQLiteOpenHelper.Configuration.builder(context) - .name(databaseName) - .callback(callback) - .build() - ) - } - - private fun SupportSQLiteDatabase.tableColumns(tableName: String): Set { - val columns = mutableSetOf() - query("PRAGMA table_info(`$tableName`)").use { cursor -> - val nameIndex = cursor.getColumnIndex("name") - while (cursor.moveToNext()) { - columns += cursor.getString(nameIndex) - } - } - return columns - } - - private fun SupportSQLiteDatabase.isColumnNotNull(tableName: String, columnName: String): Boolean { - query("PRAGMA table_info(`$tableName`)").use { cursor -> - val nameIndex = cursor.getColumnIndex("name") - val notNullIndex = cursor.getColumnIndex("notnull") - while (cursor.moveToNext()) { - if (cursor.getString(nameIndex) == columnName) { - return cursor.getInt(notNullIndex) == 1 - } - } - } - return false - } - - companion object { - private const val DB_NAME_23_TO_24_DRIFTED = "migration-test-23-to-24-drifted" - private const val DB_NAME_44_TO_45_NAVIDROME_DISC_NUMBER = - "migration-test-44-to-45-navidrome-disc-number" - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c570d92..83e4d7b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,8 @@ + + @@ -122,6 +124,12 @@ + + + diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt index a75c7ab..0ecd094 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt @@ -64,6 +64,8 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. // ADD THE COMPANION OBJECT companion object { const val NOTIFICATION_CHANNEL_ID = "pixelplayer_music_channel" + // Low-importance channel for the background library-sync foreground notification (F138). + const val SYNC_NOTIFICATION_CHANNEL_ID = "pixelplayer_sync_channel" } private val appLifecycleObserver = object : DefaultLifecycleObserver { @@ -94,8 +96,14 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. "PixelPlayerOSS Music Playback", NotificationManager.IMPORTANCE_LOW ) + val syncChannel = NotificationChannel( + SYNC_NOTIFICATION_CHANNEL_ID, + getString(R.string.sync_notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ) val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(channel) + notificationManager.createNotificationChannel(syncChannel) } ProcessLifecycleOwner.get().lifecycle.addObserver(appLifecycleObserver) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/AlbumArtThemeEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/AlbumArtThemeEntity.kt index 88f5521..bd4c4c2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/AlbumArtThemeEntity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/AlbumArtThemeEntity.kt @@ -2,8 +2,6 @@ package com.lostf1sh.pixelplayeross.data.database import androidx.room.Embedded import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey // For simplicity, we store colors as hexadecimal Strings. // Stores the color values for ONE scheme (either light or dark) @@ -60,12 +58,12 @@ data class StoredColorSchemeValues( @Entity( tableName = "album_art_themes", - indices = [ - Index(value = ["albumArtUriString", "paletteStyle"]) - ] + // Composite primary key so each (album art, palette style) variant is cached as its own row. + // Previously the PK was albumArtUriString alone, which collapsed all styles onto one row (F71). + primaryKeys = ["albumArtUriString", "paletteStyle"] ) data class AlbumArtThemeEntity( - @PrimaryKey val albumArtUriString: String, + val albumArtUriString: String, val paletteStyle: String, @Embedded(prefix = "light_") val lightThemeValues: StoredColorSchemeValues, @Embedded(prefix = "dark_") val darkThemeValues: StoredColorSchemeValues diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/LocalPlaylistDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/LocalPlaylistDao.kt index 76e7b95..8e75092 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/LocalPlaylistDao.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/LocalPlaylistDao.kt @@ -36,6 +36,14 @@ interface LocalPlaylistDao { @Query("DELETE FROM playlists WHERE id = :playlistId") suspend fun deletePlaylist(playlistId: String) + // playlist_songs has no FK/cascade, so deleting a playlist must also clear its song rows in the + // same transaction or they are orphaned forever (F73). + @Transaction + suspend fun deletePlaylistWithSongs(playlistId: String) { + clearPlaylistSongs(playlistId) + deletePlaylist(playlistId) + } + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertPlaylistSongs(entities: List) 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 8034737..e705d5e 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 @@ -1497,7 +1497,10 @@ interface MusicDao { @Query("DELETE FROM albums WHERE NOT EXISTS (SELECT 1 FROM songs WHERE songs.album_id = albums.id)") suspend fun deleteOrphanedAlbums() - @Query("DELETE FROM artists WHERE NOT EXISTS (SELECT 1 FROM song_artist_cross_ref WHERE song_artist_cross_ref.artist_id = artists.id)") + // Guard against deleting an artist still referenced by songs.artist_id: that column is NOT NULL but its + // foreign key is declared ON DELETE SET NULL, so deleting a referenced artist would abort the transaction + // with a NOT NULL violation. Excluding still-referenced artists keeps cleanup safe (F72). + @Query("DELETE FROM artists WHERE NOT EXISTS (SELECT 1 FROM song_artist_cross_ref WHERE song_artist_cross_ref.artist_id = artists.id) AND NOT EXISTS (SELECT 1 FROM songs WHERE songs.artist_id = artists.id)") suspend fun deleteOrphanedArtists() // --- Favorite Operations --- 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 cb406cc..670ea01 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 @@ -2,7 +2,6 @@ package com.lostf1sh.pixelplayeross.data.database import androidx.room.Database import androidx.room.RoomDatabase -import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase @Database( @@ -25,7 +24,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase JellyfinSongEntity::class, JellyfinPlaylistEntity::class ], - version = 45, + version = 1, exportSchema = true ) abstract class PixelPlayerDatabase : RoomDatabase() { @@ -41,955 +40,11 @@ abstract class PixelPlayerDatabase : RoomDatabase() { abstract fun jellyfinDao(): JellyfinDao companion object { - // Gap-bridging no-op migrations for missing version ranges. - // These versions predate current schema artifacts; affected tables have since - // been recreated by later migrations (e.g. 15→16 drops/recreates album_art_themes). - val MIGRATION_5_6 = object : Migration(5, 6) { - override fun migrate(db: SupportSQLiteDatabase) { /* no-op gap bridge */ } - } - val MIGRATION_7_8 = object : Migration(7, 8) { - override fun migrate(db: SupportSQLiteDatabase) { /* no-op gap bridge */ } - } - val MIGRATION_8_9 = object : Migration(8, 9) { - override fun migrate(db: SupportSQLiteDatabase) { /* no-op gap bridge */ } - } - - val MIGRATION_3_4 = object : Migration(3, 4) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE songs ADD COLUMN parent_directory_path TEXT NOT NULL DEFAULT ''") - } - } - - val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE songs ADD COLUMN lyrics TEXT") - } - } - - val MIGRATION_6_7 = object : Migration(6, 7) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE songs ADD COLUMN mime_type TEXT") - db.execSQL("ALTER TABLE songs ADD COLUMN bitrate INTEGER") - db.execSQL("ALTER TABLE songs ADD COLUMN sample_rate INTEGER") - } - } - - val MIGRATION_9_10 = object : Migration(9, 10) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE songs ADD COLUMN album_artist TEXT DEFAULT NULL") - - db.execSQL(""" - CREATE TABLE IF NOT EXISTS song_artist_cross_ref ( - 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 DELETE CASCADE, - FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE - ) - """.trimIndent()) - - db.execSQL("CREATE INDEX IF NOT EXISTS index_song_artist_cross_ref_song_id ON song_artist_cross_ref(song_id)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_song_artist_cross_ref_artist_id ON song_artist_cross_ref(artist_id)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_song_artist_cross_ref_is_primary ON song_artist_cross_ref(is_primary)") - - db.execSQL(""" - INSERT OR REPLACE INTO song_artist_cross_ref (song_id, artist_id, is_primary) - SELECT id, artist_id, 1 FROM songs WHERE artist_id IS NOT NULL - """.trimIndent()) - } - } - - val MIGRATION_10_11 = object : Migration(10, 11) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE artists ADD COLUMN image_url TEXT DEFAULT NULL") - } - } - - val MIGRATION_11_12 = object : Migration(11, 12) { - override fun migrate(db: SupportSQLiteDatabase) { /* removed provider bridge */ } - } - - val MIGRATION_12_13 = object : Migration(12, 13) { - override fun migrate(db: SupportSQLiteDatabase) { /* removed provider bridge */ } - } - - val MIGRATION_13_14 = object : Migration(13, 14) { - override fun migrate(db: SupportSQLiteDatabase) { /* removed provider bridge */ } - } - - val MIGRATION_15_16 = object : Migration(15, 16) { - override fun migrate(db: SupportSQLiteDatabase) { - // Create song_engagements table for tracking play statistics - db.execSQL(""" - CREATE TABLE IF NOT EXISTS song_engagements ( - song_id TEXT NOT NULL PRIMARY KEY, - play_count INTEGER NOT NULL DEFAULT 0, - total_play_duration_ms INTEGER NOT NULL DEFAULT 0, - last_played_timestamp INTEGER NOT NULL DEFAULT 0 - ) - """.trimIndent()) - - // Fix for album_art_themes schema mismatch (Backport upstream MIGRATION_14_15 logic) - // Since this table is a cache and the schema is complex (100 columns), it is safer to DROP and RECREATE - // to ensure it exactly matches AlbumArtThemeEntity and avoid validation crashes. - db.execSQL("DROP TABLE IF EXISTS album_art_themes") - - val colorColumns = listOf( - "primary", "onPrimary", "primaryContainer", "onPrimaryContainer", - "secondary", "onSecondary", "secondaryContainer", "onSecondaryContainer", - "tertiary", "onTertiary", "tertiaryContainer", "onTertiaryContainer", - "background", "onBackground", "surface", "onSurface", - "surfaceVariant", "onSurfaceVariant", "error", "onError", - "outline", "errorContainer", "onErrorContainer", - "inversePrimary", "inverseSurface", "inverseOnSurface", - "surfaceTint", "outlineVariant", "scrim", - "surfaceBright", "surfaceDim", - "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest", "surfaceContainerLow", "surfaceContainerLowest", - "primaryFixed", "primaryFixedDim", "onPrimaryFixed", "onPrimaryFixedVariant", - "secondaryFixed", "secondaryFixedDim", "onSecondaryFixed", "onSecondaryFixedVariant", - "tertiaryFixed", "tertiaryFixedDim", "onTertiaryFixed", "onTertiaryFixedVariant" - ) - - val themePrefixes = listOf("light_", "dark_") - val columnDefinitions = StringBuilder() - - // Add standard columns - columnDefinitions.append("albumArtUriString TEXT NOT NULL, ") - columnDefinitions.append("paletteStyle TEXT NOT NULL, ") - - // Add dynamic color columns - themePrefixes.forEach { prefix -> - colorColumns.forEach { column -> - columnDefinitions.append("${prefix}${column} TEXT NOT NULL, ") - } - } - - // Remove trailing comma and space - val columnsSql = columnDefinitions.toString().trimEnd(',', ' ') - - db.execSQL("CREATE TABLE IF NOT EXISTS album_art_themes ($columnsSql, PRIMARY KEY(albumArtUriString))") - } - } - - val MIGRATION_16_17 = object : Migration(16, 17) { - override fun migrate(db: SupportSQLiteDatabase) { - // Fix for album_art_themes schema mismatch if user is coming from version 16 (where the schema might be broken) - // We re-apply the DROP and RECREATE strategy here to ensure everyone ends up with the correct schema. - db.execSQL("DROP TABLE IF EXISTS album_art_themes") - - val colorColumns = listOf( - "primary", "onPrimary", "primaryContainer", "onPrimaryContainer", - "secondary", "onSecondary", "secondaryContainer", "onSecondaryContainer", - "tertiary", "onTertiary", "tertiaryContainer", "onTertiaryContainer", - "background", "onBackground", "surface", "onSurface", - "surfaceVariant", "onSurfaceVariant", "error", "onError", - "outline", "errorContainer", "onErrorContainer", - "inversePrimary", "inverseSurface", "inverseOnSurface", - "surfaceTint", "outlineVariant", "scrim", - "surfaceBright", "surfaceDim", - "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest", "surfaceContainerLow", "surfaceContainerLowest", - "primaryFixed", "primaryFixedDim", "onPrimaryFixed", "onPrimaryFixedVariant", - "secondaryFixed", "secondaryFixedDim", "onSecondaryFixed", "onSecondaryFixedVariant", - "tertiaryFixed", "tertiaryFixedDim", "onTertiaryFixed", "onTertiaryFixedVariant" - ) - - val themePrefixes = listOf("light_", "dark_") - val columnDefinitions = StringBuilder() - - // Add standard columns - columnDefinitions.append("albumArtUriString TEXT NOT NULL, ") - columnDefinitions.append("paletteStyle TEXT NOT NULL, ") - - // Add dynamic color columns - themePrefixes.forEach { prefix -> - colorColumns.forEach { column -> - columnDefinitions.append("${prefix}${column} TEXT NOT NULL, ") - } - } - - // Remove trailing comma and space - val columnsSql = columnDefinitions.toString().trimEnd(',', ' ') - - db.execSQL("CREATE TABLE IF NOT EXISTS album_art_themes ($columnsSql, PRIMARY KEY(albumArtUriString))") - } - } - - val MIGRATION_17_18 = object : Migration(17, 18) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL(""" - CREATE TABLE IF NOT EXISTS favorites ( - songId INTEGER NOT NULL PRIMARY KEY, - isFavorite INTEGER NOT NULL, - timestamp INTEGER NOT NULL - ) - """.trimIndent()) - - // Migrate existing favorites from songs table if possible - // Note: We need to cast is_favorite (boolean/int) to ensure compatibility - db.execSQL(""" - INSERT OR IGNORE INTO favorites (songId, isFavorite, timestamp) - SELECT id, is_favorite, ? FROM songs WHERE is_favorite = 1 - """, arrayOf(System.currentTimeMillis())) - } - } - - val MIGRATION_18_19 = object : Migration(18, 19) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( - "CREATE TABLE IF NOT EXISTS `lyrics` (`songId` INTEGER NOT NULL, `content` TEXT NOT NULL, `isSynced` INTEGER NOT NULL DEFAULT 0, `source` TEXT, PRIMARY KEY(`songId`))" - ) - database.execSQL( - "INSERT INTO lyrics (songId, content) SELECT id, lyrics FROM songs WHERE lyrics IS NOT NULL AND lyrics != ''" - ) - } - } - - val MIGRATION_14_15 = object : Migration(14, 15) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( - "ALTER TABLE album_art_themes ADD COLUMN paletteStyle TEXT NOT NULL DEFAULT 'tonal_spot'" - ) - - val newRoleColumns = listOf( - "surfaceBright", - "surfaceDim", - "surfaceContainer", - "surfaceContainerHigh", - "surfaceContainerHighest", - "surfaceContainerLow", - "surfaceContainerLowest", - "primaryFixed", - "primaryFixedDim", - "onPrimaryFixed", - "onPrimaryFixedVariant", - "secondaryFixed", - "secondaryFixedDim", - "onSecondaryFixed", - "onSecondaryFixedVariant", - "tertiaryFixed", - "tertiaryFixedDim", - "onTertiaryFixed", - "onTertiaryFixedVariant" - ) - - val prefixes = listOf("light_", "dark_") - prefixes.forEach { prefix -> - newRoleColumns.forEach { role -> - database.execSQL( - "ALTER TABLE album_art_themes ADD COLUMN ${prefix}${role} TEXT NOT NULL DEFAULT '#00000000'" - ) - } - } - - // The table is a cache; wipe stale rows so we always regenerate with full token data. - database.execSQL("DELETE FROM album_art_themes") - } - } - - val MIGRATION_19_20 = object : Migration(19, 20) { - override fun migrate(db: SupportSQLiteDatabase) { /* removed provider bridge */ } - } - - val MIGRATION_20_21 = object : Migration(20, 21) { - override fun migrate(db: SupportSQLiteDatabase) { /* removed provider bridge */ } - } - - val MIGRATION_21_22 = object : Migration(21, 22) { - override fun migrate(db: SupportSQLiteDatabase) { /* removed cloud-provider bridge */ } - } - - /** - * Add custom_image_uri column to artists table. - * Allows users to associate a custom image with each artist. - * Nullable with DEFAULT NULL so this migration is safe and additive. - */ - val MIGRATION_22_23 = object : Migration(22, 23) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE artists ADD COLUMN custom_image_uri TEXT DEFAULT NULL") - } - } - - /** - * Add missing indexes for frequently filtered and sorted queries. - * - * Safety: the `date_added` column may be absent on databases that were - * created before it was part of the songs schema and later restored via - * Platform backup, so we repair the table defensively before indexing. - */ - val MIGRATION_23_24 = object : Migration(23, 24) { - override fun migrate(db: SupportSQLiteDatabase) { - ensureSongsTableHasDateAdded(db) - createSongsEntityIndexes(db) - db.execSQL("CREATE INDEX IF NOT EXISTS index_favorites_timestamp ON favorites(timestamp)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_song_engagements_play_count ON song_engagements(play_count)") - } - } - - val MIGRATION_24_25 = object : Migration(24, 25) { - override fun migrate(db: SupportSQLiteDatabase) { - // Lyrics are already persisted in the dedicated lyrics table. Keeping a duplicate - // copy in songs rows makes broad SELECTs vulnerable to CursorWindow overflows. - db.execSQL("UPDATE songs SET lyrics = NULL WHERE lyrics IS NOT NULL AND lyrics != ''") - } - } - - val MIGRATION_25_26 = object : Migration(25, 26) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE INDEX IF NOT EXISTS index_album_art_themes_albumArtUriString_paletteStyle ON album_art_themes(albumArtUriString, paletteStyle)") - - // favorites table is the source of truth; keep songs.is_favorite mirrored by trigger. - db.execSQL( - """ - UPDATE songs - SET is_favorite = CASE - WHEN id IN (SELECT songId FROM favorites WHERE isFavorite = 1) THEN 1 - ELSE 0 - END - """.trimIndent() - ) - installFavoriteSyncTriggers(db) - - recreatePlaylistsTable(db) - db.execSQL("CREATE INDEX IF NOT EXISTS index_playlists_last_modified ON playlists(last_modified)") - - recreatePlaylistSongsTable(db) - db.execSQL("CREATE INDEX IF NOT EXISTS index_playlist_songs_playlist_id_sort_order ON playlist_songs(playlist_id, sort_order)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_playlist_songs_song_id ON playlist_songs(song_id)") - } - } - - val MIGRATION_26_27 = object : Migration(26, 27) { - override fun migrate(db: SupportSQLiteDatabase) { - recreatePlaylistsTable(db) - db.execSQL("CREATE INDEX IF NOT EXISTS index_playlists_last_modified ON playlists(last_modified)") - - recreatePlaylistSongsTable(db) - db.execSQL("CREATE INDEX IF NOT EXISTS index_playlist_songs_playlist_id_sort_order ON playlist_songs(playlist_id, sort_order)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_playlist_songs_song_id ON playlist_songs(song_id)") - installFavoriteSyncTriggers(db) - } - } - - val MIGRATION_36_37 = object : Migration(36, 37) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL(""" - CREATE TABLE IF NOT EXISTS `jellyfin_songs` ( - `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` 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`) - ) - """.trimIndent()) - - db.execSQL("CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_jellyfin_id` ON `jellyfin_songs` (`jellyfin_id`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id` ON `jellyfin_songs` (`playlist_id`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id_date_added` ON `jellyfin_songs` (`playlist_id`, `date_added`)") - - db.execSQL(""" - CREATE TABLE IF NOT EXISTS `jellyfin_playlists` ( - `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`) - ) - """.trimIndent()) - } - } - - val MIGRATION_37_38 = object : Migration(37, 38) { - override fun migrate(db: SupportSQLiteDatabase) { /* removed analytics-provider bridge */ } - } - - val MIGRATION_38_39 = object : Migration(38, 39) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE INDEX IF NOT EXISTS index_songs_file_path ON songs(file_path)") - } - } - - val MIGRATION_39_40 = object : Migration(39, 40) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - "CREATE INDEX IF NOT EXISTS index_songs_parent_directory_path_source_type_album_id " + - "ON songs(parent_directory_path, source_type, album_id)" - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS index_songs_parent_directory_path_source_type_id " + - "ON songs(parent_directory_path, source_type, id)" - ) - } - } - - val MIGRATION_40_41 = object : Migration(40, 41) { - override fun migrate(db: SupportSQLiteDatabase) { - if ("album_artist" !in getTableColumns(db, "albums")) { - db.execSQL("ALTER TABLE albums ADD COLUMN album_artist TEXT DEFAULT NULL") - } - db.execSQL( - """ - UPDATE albums - SET album_artist = ( - SELECT s.album_artist - FROM songs s - WHERE s.album_id = albums.id - AND s.album_artist IS NOT NULL - AND TRIM(s.album_artist) != '' - GROUP BY s.album_artist - ORDER BY COUNT(*) DESC, LENGTH(s.album_artist) DESC - LIMIT 1 - ) - """.trimIndent() - ) - db.execSQL("CREATE INDEX IF NOT EXISTS index_albums_album_artist ON albums(album_artist)") - } - } - - private fun removedIdentifier(vararg chars: Char): String = chars.concatToString() - - val MIGRATION_41_42 = object : Migration(41, 42) { - override fun migrate(db: SupportSQLiteDatabase) { - val removedLocalFeature = removedIdentifier('a', 'i') - val removedCloudProvider = removedIdentifier('g', 'd', 'r', 'i', 'v', 'e') - listOf( - removedLocalFeature + "_cache", - removedLocalFeature + "_usage", - removedCloudProvider + "_songs", - removedCloudProvider + "_folders" - ).forEach { tableName -> - db.execSQL("DROP TABLE IF EXISTS $tableName") - } - val removedScheme = removedCloudProvider + "://%" - db.execSQL( - "DELETE FROM playlist_songs WHERE song_id IN (" + - "SELECT id FROM songs WHERE source_type = 3 OR content_uri_string LIKE '$removedScheme'" + - ")" - ) - db.execSQL("DELETE FROM songs WHERE source_type = 3 OR content_uri_string LIKE '$removedScheme'") - recreatePlaylistsTable(db) - db.execSQL("CREATE INDEX IF NOT EXISTS index_playlists_last_modified ON playlists(last_modified)") - } - } - - val MIGRATION_42_43 = object : Migration(42, 43) { - override fun migrate(db: SupportSQLiteDatabase) { - val removedProviderA = removedIdentifier('t', 'e', 'l', 'e', 'g', 'r', 'a', 'm') - val removedProviderB = removedIdentifier('n', 'e', 't', 'e', 'a', 's', 'e') - val removedProviderC = removedIdentifier('q', 'q', 'm', 'u', 's', 'i', 'c') - val removedTables = listOf( - removedProviderA + "_songs", - removedProviderA + "_channels", - removedProviderA + "_topics", - removedProviderB + "_songs", - removedProviderB + "_playlists", - removedProviderC + "_songs", - removedProviderC + "_playlists" - ) - val removedSchemes = listOf( - removedProviderA + "://%", - removedProviderB + "://%", - removedProviderC + "://%" - ) - - val removedSongWhere = buildString { - append("source_type IN (1, 2, 4)") - removedSchemes.forEach { scheme -> - append(" OR content_uri_string LIKE '") - append(scheme) - append("'") - } - } - - db.execSQL( - "DELETE FROM playlist_songs WHERE song_id IN (SELECT id FROM songs WHERE $removedSongWhere)" - ) - db.execSQL("DELETE FROM songs WHERE $removedSongWhere") - recreateSongsTable(db) - removedTables.forEach { tableName -> - db.execSQL("DROP TABLE IF EXISTS $tableName") - } - recreatePlaylistsTable(db) - db.execSQL("CREATE INDEX IF NOT EXISTS index_playlists_last_modified ON playlists(last_modified)") - } - } - - val MIGRATION_43_44 = object : Migration(43, 44) { - override fun migrate(db: SupportSQLiteDatabase) { - addColumnIfMissing(db, "songs", "media_store_date_added", "INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "songs", "media_store_date_modified", "INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "songs", "title_user_edited", "INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "songs", "artist_user_edited", "INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "songs", "album_user_edited", "INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "songs", "genre_user_edited", "INTEGER NOT NULL DEFAULT 0") - - db.execSQL( - "UPDATE songs SET media_store_date_modified = CASE " + - "WHEN date_added > 0 THEN date_added / 1000 ELSE 0 END " + - "WHERE media_store_date_modified = 0" - ) - db.execSQL( - "UPDATE songs SET media_store_date_added = media_store_date_modified " + - "WHERE media_store_date_added = 0" - ) - } - } - - val MIGRATION_44_45 = object : Migration(44, 45) { - override fun migrate(db: SupportSQLiteDatabase) { - recreateNavidromeSongsTable(db) - } - } - - private fun ensureSongsTableHasDateAdded(db: SupportSQLiteDatabase) { - if (!tableExists(db, "songs")) { - recreateSongsTable(db) - return - } - - if ("date_added" in getTableColumns(db, "songs")) { - return - } - - try { - db.execSQL("ALTER TABLE songs ADD COLUMN date_added INTEGER NOT NULL DEFAULT 0") - } catch (_: Exception) { - // Some restored databases report the right version but still carry - // a drifted songs table. If ALTER TABLE did not stick, rebuild it. - } - - if ("date_added" !in getTableColumns(db, "songs")) { - recreateSongsTable(db) - } - } - - private fun recreateSongsTable(db: SupportSQLiteDatabase) { - val songsTableExists = tableExists(db, "songs") - val columns = if (songsTableExists) getTableColumns(db, "songs") else emptySet() - - db.execSQL("DROP TABLE IF EXISTS songs_new") - db.execSQL( - """ - CREATE TABLE songs_new ( - id INTEGER NOT NULL, - title TEXT NOT NULL, - artist_name TEXT NOT NULL, - artist_id INTEGER NOT NULL, - album_artist TEXT, - 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 DEFAULT NULL, - source_type INTEGER NOT NULL DEFAULT 0, - media_store_date_added INTEGER NOT NULL DEFAULT 0, - media_store_date_modified INTEGER NOT NULL DEFAULT 0, - title_user_edited INTEGER NOT NULL DEFAULT 0, - artist_user_edited INTEGER NOT NULL DEFAULT 0, - album_user_edited INTEGER NOT NULL DEFAULT 0, - genre_user_edited INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY(id), - FOREIGN KEY(album_id) REFERENCES albums(id) ON UPDATE NO ACTION ON DELETE CASCADE, - FOREIGN KEY(artist_id) REFERENCES artists(id) ON UPDATE NO ACTION ON DELETE SET NULL - ) - """.trimIndent() - ) - - val requiredColumns = setOf( - "id", - "title", - "artist_name", - "artist_id", - "album_name", - "album_id", - "content_uri_string", - "duration", - "file_path" - ) - - // If the restored table still has the core song columns, preserve rows. - // Otherwise prefer a clean empty table over another migration-time crash. - if (songsTableExists && requiredColumns.all(columns::contains)) { - val albumArtistExpr = columnExpr(columns, "album_artist", "NULL") - val albumArtUriExpr = columnExpr(columns, "album_art_uri_string", "NULL") - val genreExpr = columnExpr(columns, "genre", "NULL") - val parentDirectoryPathExpr = columnExpr(columns, "parent_directory_path", "''") - val isFavoriteExpr = columnExpr(columns, "is_favorite", "0") - val lyricsExpr = columnExpr(columns, "lyrics", "NULL") - val trackNumberExpr = columnExpr(columns, "track_number", "0") - val discNumberExpr = columnExpr(columns, "disc_number", "NULL") - val yearExpr = columnExpr(columns, "year", "0") - val dateAddedExpr = columnExpr(columns, "date_added", "0") - val mimeTypeExpr = columnExpr(columns, "mime_type", "NULL") - val bitrateExpr = columnExpr(columns, "bitrate", "NULL") - val sampleRateExpr = columnExpr(columns, "sample_rate", "NULL") - val mediaStoreDateModifiedExpr = columnExpr( - columns, - "media_store_date_modified", - "CASE WHEN $dateAddedExpr > 0 THEN $dateAddedExpr / 1000 ELSE 0 END" - ) - - db.execSQL( - """ - INSERT OR REPLACE INTO songs_new ( - id, - title, - artist_name, - artist_id, - album_artist, - album_name, - album_id, - content_uri_string, - album_art_uri_string, - duration, - genre, - file_path, - parent_directory_path, - is_favorite, - lyrics, - track_number, - disc_number, - year, - date_added, - mime_type, - bitrate, - sample_rate, - artists_json, - source_type, - media_store_date_added, - media_store_date_modified, - title_user_edited, - artist_user_edited, - album_user_edited, - genre_user_edited - ) - SELECT - id, - title, - artist_name, - artist_id, - $albumArtistExpr, - album_name, - album_id, - content_uri_string, - $albumArtUriExpr, - duration, - $genreExpr, - file_path, - $parentDirectoryPathExpr, - $isFavoriteExpr, - $lyricsExpr, - $trackNumberExpr, - $discNumberExpr, - $yearExpr, - $dateAddedExpr, - $mimeTypeExpr, - $bitrateExpr, - $sampleRateExpr, - ${columnExpr(columns, "artists_json", "NULL")}, - ${columnExpr(columns, "source_type", "0")}, - ${columnExpr(columns, "media_store_date_added", mediaStoreDateModifiedExpr)}, - $mediaStoreDateModifiedExpr, - ${columnExpr(columns, "title_user_edited", "0")}, - ${columnExpr(columns, "artist_user_edited", "0")}, - ${columnExpr(columns, "album_user_edited", "0")}, - ${columnExpr(columns, "genre_user_edited", "0")} - FROM songs - WHERE id IS NOT NULL - AND title IS NOT NULL - AND artist_name IS NOT NULL - AND artist_id IS NOT NULL - AND album_name IS NOT NULL - AND album_id IS NOT NULL - AND content_uri_string IS NOT NULL - AND duration IS NOT NULL - AND file_path IS NOT NULL - """.trimIndent() - ) - } - - if (songsTableExists) { - db.execSQL("DROP TABLE songs") - } - - db.execSQL("ALTER TABLE songs_new RENAME TO songs") - createSongsEntityIndexes(db) - } - - private fun createSongsEntityIndexes(db: SupportSQLiteDatabase) { - val columns = getTableColumns(db, "songs") - - fun createIndexIfColumnExists(columnName: String, indexName: String) { - if (columnName in columns) { - db.execSQL("CREATE INDEX IF NOT EXISTS $indexName ON songs($columnName)") - } - } - - fun createCompositeIndexIfColumnsExist(indexName: String, vararg columnNames: String) { - if (columnNames.all(columns::contains)) { - db.execSQL( - "CREATE INDEX IF NOT EXISTS $indexName ON songs(${columnNames.joinToString(", ")})" - ) - } - } - - createIndexIfColumnExists("title", "index_songs_title") - createIndexIfColumnExists("album_id", "index_songs_album_id") - createIndexIfColumnExists("artist_id", "index_songs_artist_id") - createIndexIfColumnExists("artist_name", "index_songs_artist_name") - createIndexIfColumnExists("genre", "index_songs_genre") - createIndexIfColumnExists("parent_directory_path", "index_songs_parent_directory_path") - createIndexIfColumnExists("file_path", "index_songs_file_path") - createIndexIfColumnExists("content_uri_string", "index_songs_content_uri_string") - createIndexIfColumnExists("date_added", "index_songs_date_added") - createIndexIfColumnExists("duration", "index_songs_duration") - createIndexIfColumnExists("source_type", "index_songs_source_type") - createCompositeIndexIfColumnsExist( - "index_songs_parent_directory_path_source_type_album_id", - "parent_directory_path", - "source_type", - "album_id" - ) - createCompositeIndexIfColumnsExist( - "index_songs_parent_directory_path_source_type_id", - "parent_directory_path", - "source_type", - "id" - ) - } - - private fun recreatePlaylistsTable(db: SupportSQLiteDatabase) { - db.execSQL("DROP TABLE IF EXISTS playlists_new") - db.execSQL( - """ - CREATE TABLE playlists_new ( - id TEXT NOT NULL PRIMARY KEY, - 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 - ) - """.trimIndent() - ) - - if (tableExists(db, "playlists")) { - val columns = getTableColumns(db, "playlists") - if ("id" in columns && "name" in columns) { - val nowMs = "(CAST(strftime('%s','now') AS INTEGER) * 1000)" - val createdAtExpr = columnExpr(columns, "created_at", nowMs) - val lastModifiedExpr = columnExpr(columns, "last_modified", createdAtExpr) - val isQueueGeneratedExpr = columnExpr(columns, "is_queue_generated", "0") - val coverImageUriExpr = columnExpr(columns, "cover_image_uri", "NULL") - val coverColorArgbExpr = columnExpr(columns, "cover_color_argb", "NULL") - val coverIconNameExpr = columnExpr(columns, "cover_icon_name", "NULL") - val coverShapeTypeExpr = columnExpr(columns, "cover_shape_type", "NULL") - val coverShapeDetail1Expr = columnExpr(columns, "cover_shape_detail_1", "NULL") - val coverShapeDetail2Expr = columnExpr(columns, "cover_shape_detail_2", "NULL") - val coverShapeDetail3Expr = columnExpr(columns, "cover_shape_detail_3", "NULL") - val coverShapeDetail4Expr = columnExpr(columns, "cover_shape_detail_4", "NULL") - val sourceExpr = columnExpr(columns, "source", "'LOCAL'") - - db.execSQL( - """ - INSERT OR REPLACE INTO playlists_new ( - id, - name, - created_at, - last_modified, - is_queue_generated, - cover_image_uri, - cover_color_argb, - cover_icon_name, - cover_shape_type, - cover_shape_detail_1, - cover_shape_detail_2, - cover_shape_detail_3, - cover_shape_detail_4, - source - ) - SELECT - id, - name, - $createdAtExpr, - $lastModifiedExpr, - $isQueueGeneratedExpr, - $coverImageUriExpr, - $coverColorArgbExpr, - $coverIconNameExpr, - $coverShapeTypeExpr, - $coverShapeDetail1Expr, - $coverShapeDetail2Expr, - $coverShapeDetail3Expr, - $coverShapeDetail4Expr, - $sourceExpr - FROM playlists - WHERE id IS NOT NULL AND name IS NOT NULL - """.trimIndent() - ) - } - db.execSQL("DROP TABLE playlists") - } - - db.execSQL("ALTER TABLE playlists_new RENAME TO playlists") - } - - private fun recreatePlaylistSongsTable(db: SupportSQLiteDatabase) { - db.execSQL("DROP TABLE IF EXISTS playlist_songs_new") - db.execSQL( - """ - CREATE TABLE playlist_songs_new ( - playlist_id TEXT NOT NULL, - song_id TEXT NOT NULL, - sort_order INTEGER NOT NULL, - PRIMARY KEY(playlist_id, song_id) - ) - """.trimIndent() - ) - - if (tableExists(db, "playlist_songs")) { - val columns = getTableColumns(db, "playlist_songs") - if ("playlist_id" in columns && "song_id" in columns) { - val sortOrderExpr = columnExpr(columns, "sort_order", "0") - db.execSQL( - """ - INSERT OR REPLACE INTO playlist_songs_new ( - playlist_id, - song_id, - sort_order - ) - SELECT - playlist_id, - song_id, - $sortOrderExpr - FROM playlist_songs - WHERE playlist_id IS NOT NULL AND song_id IS NOT NULL - """.trimIndent() - ) - } - db.execSQL("DROP TABLE playlist_songs") - } - - db.execSQL("ALTER TABLE playlist_songs_new RENAME TO playlist_songs") - } - - private fun tableExists(db: SupportSQLiteDatabase, tableName: String): Boolean { - db.query( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", - arrayOf(tableName) - ).use { cursor -> - return cursor.moveToFirst() - } - } - - private fun getTableColumns(db: SupportSQLiteDatabase, tableName: String): Set { - val columns = mutableSetOf() - db.query("PRAGMA table_info(`$tableName`)").use { cursor -> - val nameIndex = cursor.getColumnIndex("name") - if (nameIndex == -1) return columns - while (cursor.moveToNext()) { - columns += cursor.getString(nameIndex) - } - } - return columns - } - - private fun addColumnIfMissing( - db: SupportSQLiteDatabase, - tableName: String, - columnName: String, - definition: String - ) { - if (!tableExists(db, tableName)) return - if (columnName !in getTableColumns(db, tableName)) { - db.execSQL("ALTER TABLE $tableName ADD COLUMN $columnName $definition") - } - } - - private fun getTableColumnDefaultValue( - db: SupportSQLiteDatabase, - tableName: String, - columnName: String - ): String? { - db.query("PRAGMA table_info(`$tableName`)").use { cursor -> - val nameIndex = cursor.getColumnIndex("name") - val defaultValueIndex = cursor.getColumnIndex("dflt_value") - if (nameIndex == -1 || defaultValueIndex == -1) return null - - while (cursor.moveToNext()) { - if (cursor.getString(nameIndex) == columnName) { - return cursor.getString(defaultValueIndex) - } - } - } - return null - } - - private fun ensureSongsTableHasDiscNumber(db: SupportSQLiteDatabase) { - if (!tableExists(db, "songs")) { - recreateSongsTable(db) - return - } - - val columns = getTableColumns(db, "songs") - if ("disc_number" !in columns) { - try { - db.execSQL("ALTER TABLE songs ADD COLUMN disc_number INTEGER DEFAULT null") - } catch (_: Exception) { - // Restored/drifted databases may already contain a partially applied column. - } - } - - val refreshedColumns = getTableColumns(db, "songs") - val discNumberDefault = getTableColumnDefaultValue(db, "songs", "disc_number") - - if ("disc_number" !in refreshedColumns || !discNumberDefault.equals("null", ignoreCase = true)) { - recreateSongsTable(db) - } - } - - private fun columnExpr(columns: Set, columnName: String, fallbackExpr: String): String { - return if (columnName in columns) { - "COALESCE($columnName, $fallbackExpr)" - } else { - fallbackExpr - } - } + // The schema starts at version 1 (first release). The favorites/FTS mirroring triggers and the + // songs_fts virtual table are NOT managed by Room from the @Database entity list, so they are + // (re)created here on fresh database creation via createRuntimeArtifactsCallback(). + // The `favorites` table is the source of truth: trg_favorites_* mirror is_favorite onto songs, + // and trg_songs_fts_* keep the FTS search index in sync. fun installFavoriteSyncTriggers(db: SupportSQLiteDatabase) { db.execSQL("DROP TRIGGER IF EXISTS trg_favorites_insert_sync_song") @@ -1081,17 +136,6 @@ abstract class PixelPlayerDatabase : RoomDatabase() { ) } - private fun rebuildSongsSearchIndex(db: SupportSQLiteDatabase) { - db.execSQL("DELETE FROM songs_fts") - db.execSQL( - """ - INSERT INTO songs_fts(rowid, title, artist_name) - SELECT id, title, artist_name - FROM songs - """.trimIndent() - ) - } - fun createRuntimeArtifactsCallback(): RoomDatabase.Callback { return object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { @@ -1101,218 +145,5 @@ abstract class PixelPlayerDatabase : RoomDatabase() { } } } - - val MIGRATION_27_28 = object : Migration(27, 28) { - override fun migrate(db: SupportSQLiteDatabase) { /* removed provider bridge */ } - } - - /** - * Add Navidrome/Subsonic support tables. - */ - val MIGRATION_28_29 = object : Migration(28, 29) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL(""" - CREATE TABLE IF NOT EXISTS navidrome_playlists ( - id TEXT NOT NULL PRIMARY KEY, - 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 - ) - """.trimIndent()) - - recreateNavidromeSongsTable(db) - } - } - - /** - * Reconcile older Navidrome caches that were created with playlist_id stored as INTEGER. - */ - val MIGRATION_29_30 = object : Migration(29, 30) { - override fun migrate(db: SupportSQLiteDatabase) { - recreateNavidromeSongsTable(db) - } - } - - /** - * Add disc_number to songs table. - */ - val MIGRATION_30_31 = object : Migration(30, 31) { - override fun migrate(db: SupportSQLiteDatabase) { - ensureSongsTableHasDiscNumber(db) - } - } - - private fun recreateNavidromeSongsTable(db: SupportSQLiteDatabase) { - db.execSQL("DROP TABLE IF EXISTS navidrome_songs_new") - db.execSQL( - """ - CREATE TABLE navidrome_songs_new ( - id TEXT NOT NULL PRIMARY KEY, - navidrome_id TEXT NOT NULL, - playlist_id TEXT NOT NULL, - title TEXT NOT NULL, - artist TEXT NOT NULL, - artist_id 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 - ) - """.trimIndent() - ) - - if (tableExists(db, "navidrome_songs")) { - val columns = getTableColumns(db, "navidrome_songs") - val requiredColumns = setOf( - "id", - "navidrome_id", - "playlist_id", - "title", - "artist", - "album", - "duration", - "track_number", - "year", - "path", - "date_added" - ) - - if (requiredColumns.all(columns::contains)) { - val artistIdExpr = columnExpr(columns, "artist_id", "NULL") - val albumIdExpr = columnExpr(columns, "album_id", "NULL") - val coverArtIdExpr = columnExpr(columns, "cover_art_id", "NULL") - val genreExpr = columnExpr(columns, "genre", "NULL") - val bitRateExpr = columnExpr(columns, "bitRate", "NULL") - val mimeTypeExpr = columnExpr(columns, "mime_type", "NULL") - val suffixExpr = columnExpr(columns, "suffix", "NULL") - val discNumberExpr = if ("disc_number" in columns) "COALESCE(disc_number, 0)" else "0" - - db.execSQL( - """ - INSERT OR REPLACE INTO navidrome_songs_new ( - id, - navidrome_id, - playlist_id, - title, - artist, - artist_id, - album, - album_id, - cover_art_id, - duration, - track_number, - $discNumberExpr, - year, - genre, - bitRate, - mime_type, - suffix, - path, - date_added - ) - SELECT - id, - navidrome_id, - CAST(playlist_id AS TEXT), - title, - artist, - $artistIdExpr, - album, - $albumIdExpr, - $coverArtIdExpr, - duration, - track_number, - disc_number, - year, - $genreExpr, - $bitRateExpr, - $mimeTypeExpr, - $suffixExpr, - path, - date_added - FROM navidrome_songs - WHERE id IS NOT NULL - AND navidrome_id IS NOT NULL - AND playlist_id IS NOT NULL - AND title IS NOT NULL - AND artist IS NOT NULL - AND album IS NOT NULL - AND path IS NOT NULL - """.trimIndent() - ) - } - - db.execSQL("DROP TABLE navidrome_songs") - } - - db.execSQL("ALTER TABLE navidrome_songs_new RENAME TO navidrome_songs") - db.execSQL("CREATE INDEX IF NOT EXISTS index_navidrome_songs_navidrome_id ON navidrome_songs(navidrome_id)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_navidrome_songs_playlist_id ON navidrome_songs(playlist_id)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_navidrome_songs_playlist_id_date_added ON navidrome_songs(playlist_id, date_added)") - } - - val MIGRATION_31_32 = object : Migration(31, 32) { - override fun migrate(db: SupportSQLiteDatabase) { /* removed provider bridge */ } - } - - val MIGRATION_32_33 = object : Migration(32, 33) { - override fun migrate(db: SupportSQLiteDatabase) { - if ("date_added" !in getTableColumns(db, "albums")) { - db.execSQL("ALTER TABLE albums ADD COLUMN date_added INTEGER NOT NULL DEFAULT 0") - } - - db.execSQL( - """ - UPDATE albums - SET date_added = COALESCE( - ( - SELECT MAX(songs.date_added) - FROM songs - WHERE songs.album_id = albums.id - ), - 0 - ) - """.trimIndent() - ) - } - } - - val MIGRATION_33_34 = object : Migration(33, 34) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE songs ADD COLUMN artists_json TEXT DEFAULT NULL") - } - } - - val MIGRATION_34_35 = object : Migration(34, 35) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE songs ADD COLUMN source_type INTEGER NOT NULL DEFAULT 0") - db.execSQL("CREATE INDEX IF NOT EXISTS index_songs_source_type ON songs(source_type)") - // Backfill source_type from content_uri_string for existing rows. - db.execSQL("UPDATE songs SET source_type = 5 WHERE content_uri_string LIKE 'navidrome://%'") - } - } - - val MIGRATION_35_36 = object : Migration(35, 36) { - override fun migrate(db: SupportSQLiteDatabase) { - createSongsSearchVirtualTable(db) - installSongsSearchSyncTriggers(db) - rebuildSongsSearchIndex(db) - } - } - } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/TransitionDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/TransitionDao.kt index 7c5e240..25f8eea 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/TransitionDao.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/TransitionDao.kt @@ -21,6 +21,22 @@ interface TransitionDao { @Upsert suspend fun setRules(rules: List) + /** + * Upserts a single rule, deduping the per-playlist default rule (both track ids null) by hand. + * The unique index on (playlistId, fromTrackId, toTrackId) treats SQL NULLs as distinct, so a plain + * @Upsert keeps inserting duplicate default rules instead of replacing the existing one. Deleting any + * existing default first also cleans up duplicates left by the previous behavior (F76). + */ + @Transaction + suspend fun upsertRule(rule: TransitionRuleEntity) { + if (rule.fromTrackId == null && rule.toTrackId == null) { + deletePlaylistDefaultRule(rule.playlistId) + setRule(rule.copy(id = 0)) + } else { + setRule(rule) + } + } + /** * Gets the default transition rule for a given playlist. * A default rule is one where fromTrackId and toTrackId are both null. diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt index 59b1394..5bcabc6 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt @@ -54,6 +54,8 @@ class JellyfinRepository @Inject constructor( private const val KEY_USERNAME = "username" private const val KEY_ACCESS_TOKEN = "access_token" private const val KEY_USER_ID = "user_id" + private const val KEY_LAST_FULL_SYNC = "last_full_sync" + private const val SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000L // 24 hours private const val JELLYFIN_SONG_ID_OFFSET = 12_000_000_000_000L private const val JELLYFIN_ALBUM_ID_OFFSET = 13_000_000_000_000L @@ -83,6 +85,17 @@ class JellyfinRepository @Inject constructor( private val _isLoggedInFlow = MutableStateFlow(false) val isLoggedInFlow: StateFlow = _isLoggedInFlow.asStateFlow() + /** Wall-clock millis of the last successful full library+playlist sync (0 if never). */ + private var lastFullSyncTime: Long + get() = prefs.getLong(KEY_LAST_FULL_SYNC, 0L) + set(value) { + prefs.edit().putLong(KEY_LAST_FULL_SYNC, value).apply() + } + + /** True when the cached library has not been fully synced within the staleness threshold (F111). */ + fun isLibrarySyncStale(): Boolean = + System.currentTimeMillis() - lastFullSyncTime > SYNC_THRESHOLD_MS + init { initFromSavedCredentials() } @@ -346,11 +359,15 @@ class JellyfinRepository @Inject constructor( return withContext(Dispatchers.IO) { var syncedSongCount = 0 var failedPlaylistCount = 0 + var allOperationsSucceeded = true val libResult = syncLibrarySongs() libResult.fold( onSuccess = { count -> syncedSongCount += count }, - onFailure = { Timber.w(it, "$TAG: Failed syncing library songs") } + onFailure = { + allOperationsSucceeded = false + Timber.w(it, "$TAG: Failed syncing library songs") + } ) val playlistResult = syncPlaylists().getOrElse { @@ -359,9 +376,9 @@ class JellyfinRepository @Inject constructor( } catch (e: Exception) { Timber.e(e, "$TAG: Failed to sync unified library after playlist fetch failure") } - return@withContext Result.success( - BulkSyncResult(playlistCount = 0, syncedSongCount = syncedSongCount, failedPlaylistCount = 0) - ) + // Propagate the playlist-fetch failure instead of masking it as success, so the + // dashboard surfaces an error (and the stale-gate retries) rather than a green banner. + return@withContext Result.failure(it) } playlistResult.forEach { playlist -> @@ -378,9 +395,16 @@ class JellyfinRepository @Inject constructor( try { syncUnifiedLibrarySongsFromJellyfin() } catch (e: Exception) { + allOperationsSucceeded = false Timber.e(e, "$TAG: Failed to sync unified library") } + // Only mark a successful full sync when every step actually succeeded; otherwise leave + // lastFullSyncTime unchanged so the stale-gate retries instead of caching a partial sync (F111). + if (allOperationsSucceeded && failedPlaylistCount == 0) { + lastFullSyncTime = System.currentTimeMillis() + } + Result.success( BulkSyncResult( playlistCount = playlistResult.size, 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 2c66211..ad85828 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 @@ -529,12 +529,16 @@ class SongMetadataEditor( Timber.tag(TAG).e("METADATA_EDIT: Writer failed on temp file ${tempFile.absolutePath}") return false } - // Stream edited bytes back to the original path (truncate + overwrite). - tempFile.inputStream().use { input -> - FileOutputStream(originalFile, false).use { out -> input.copyTo(out) } + // Durably stage the edited bytes next to the original, then atomically swap + // them in. The original is never truncated until the new bytes are on disk, + // so an interrupted/failed copy-back leaves the original intact. + val replaceOk = safelyReplaceFileContents(originalFile, tempFile) + if (!replaceOk) { + Timber.tag(TAG).e("METADATA_EDIT: Failed to atomically replace $originalPath") + return false } Timber.tag(TAG).d( - "METADATA_EDIT: Restored ${tempFile.length()} edited bytes back to $originalPath" + "METADATA_EDIT: Restored ${originalFile.length()} edited bytes back to $originalPath" ) true } catch (e: Exception) { @@ -547,6 +551,60 @@ class SongMetadataEditor( } } + /** + * Durably replaces [target]'s contents with the bytes from [source] without ever leaving the + * target truncated or empty on failure. + * + * Strategy: copy [source]'s bytes into a sibling staging file in the SAME directory as [target] + * (so an atomic rename can stay on one filesystem), fsync the staging file, then atomically + * rename it over [target]. The original is never opened for truncation; if any step throws the + * original is left intact and the staging file is cleaned up, so an interrupted/failed write + * (disk full, transient I/O, process kill) cannot corrupt or zero out the user's media file. + * + * If a same-directory staging file cannot be created or the atomic rename fails, the edit is + * failed (returns false) rather than overwriting the original in place: an in-place copy must + * open the original for truncation, so a mid-write failure (disk full, kill) would corrupt or + * zero out the user's media file — exactly what this routine exists to prevent. + */ + private fun safelyReplaceFileContents(target: File, source: File): Boolean { + val parent = target.absoluteFile.parentFile + if (parent == null) { + // No directory to stage a sibling temp file in, so an atomic same-filesystem rename is + // impossible. Refuse rather than truncate the original with an in-place overwrite. + Timber.tag(TAG).e( + "METADATA_EDIT: No parent directory for ${target.absolutePath}; refusing in-place overwrite" + ) + return false + } + + val staging = File(parent, ".${target.name}.metaedit_${System.nanoTime()}.tmp") + return try { + source.inputStream().use { input -> + FileOutputStream(staging).use { out -> + input.copyTo(out) + out.fd.sync() + } + } + val renamed = staging.renameTo(target) + if (!renamed) { + // renameTo can fail across odd filesystems. Rather than fall back to an in-place + // copy that truncates the original (a mid-copy failure would corrupt the user's + // media file), fail the edit and leave the original intact. + Timber.tag(TAG).e( + "METADATA_EDIT: Atomic rename to ${target.absolutePath} failed; refusing in-place overwrite to protect the original" + ) + } + renamed + } catch (e: Exception) { + Timber.tag(TAG).e(e, "METADATA_EDIT: Safe replace failed for ${target.absolutePath}") + false + } finally { + if (staging.exists() && !staging.delete()) { + Timber.tag(TAG).w("METADATA_EDIT: Could not delete staging file ${staging.absolutePath}") + } + } + } + /** * FLAC files with high sample rates (>96kHz) or bit depths (>24bit) can cause issues with TagLib. * This function detects such files and logs warnings. @@ -1010,12 +1068,13 @@ class SongMetadataEditor( } Timber.tag(TAG) .e("VORBISJAVA: Temp file size: ${tempFile.length()} bytes, original: ${audioFile.length()} bytes") - - tempFile.inputStream().use { input -> - FileOutputStream(audioFile, false).use { output -> - input.copyTo(output) - output.fd.sync() - } + + // Atomically swap the edited bytes in via a same-directory staging file + fsync + rename. + // The original is never truncated before the new bytes are durable, so an interrupted + // copy-back cannot leave the .opus file empty/corrupted. + if (!safelyReplaceFileContents(audioFile, tempFile)) { + Timber.tag(TAG).e("VORBISJAVA: Failed to atomically replace ${audioFile.path}") + return false } Timber.tag(TAG).e("VORBISJAVA: SUCCESS - Updated file metadata: ${audioFile.path}") diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt index 41cf1ac..99c6bd8 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt @@ -81,6 +81,14 @@ class NavidromeRepository @Inject constructor( const val LIBRARY_PLAYLIST_ID = "__library__" } + private val _credentialsUnencryptedFlow = MutableStateFlow(false) + + /** + * Emits true when secure storage (EncryptedSharedPreferences) was unavailable and credentials + * had to fall back to plaintext SharedPreferences. The UI surfaces a warning when this is true. + */ + val credentialsUnencryptedFlow: StateFlow = _credentialsUnencryptedFlow.asStateFlow() + private val prefs: SharedPreferences = try { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) @@ -94,6 +102,7 @@ class NavidromeRepository @Inject constructor( ) } catch (e: Exception) { Timber.e(e, "$TAG: Failed to create EncryptedSharedPreferences, falling back to plain") + _credentialsUnencryptedFlow.value = true context.getSharedPreferences("${PREFS_NAME}_plain", Context.MODE_PRIVATE) } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/paging/MediaStorePagingSource.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/paging/MediaStorePagingSource.kt index bf027df..cbb8684 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/paging/MediaStorePagingSource.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/paging/MediaStorePagingSource.kt @@ -28,29 +28,30 @@ class MediaStorePagingSource( } override fun getRefreshKey(state: PagingState): Int? { + // Keys are absolute item offsets into filteredIds. Recover the offset of the + // page closest to the anchor so a refresh reloads from roughly the same spot, + // independent of any per-load loadSize variation. Use the anchor page's actual + // loaded size rather than config.pageSize — the latter is wrong for the initial + // load (initialLoadSize defaults to 3 * pageSize) and would skip items on refresh. return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) + val loadedPageSize = anchorPage?.data?.size?.takeIf { it > 0 } + ?: state.config.pageSize + anchorPage?.prevKey?.plus(loadedPageSize) + ?: anchorPage?.nextKey?.minus(loadedPageSize) } } override suspend fun load(params: LoadParams): LoadResult = withContext(Dispatchers.IO) { + // The page key is an absolute item offset into filteredIds (not a page index), + // so the math stays correct even when Paging3 uses a different loadSize for the + // initial refresh (initialLoadSize) than for subsequent append/prepend loads. + val start = (params.key ?: 0).coerceAtLeast(0) try { - val pageIndex = params.key ?: 0 - - if (filteredIds.isEmpty()) { + if (filteredIds.isEmpty() || start >= filteredIds.size) { return@withContext LoadResult.Page( data = emptyList(), - prevKey = null, - nextKey = null - ) - } - - val start = pageIndex * params.loadSize - if (start >= filteredIds.size) { - return@withContext LoadResult.Page( - data = emptyList(), - prevKey = if (pageIndex > 0) pageIndex - 1 else null, + prevKey = if (start > 0) (start - params.loadSize).coerceAtLeast(0) else null, nextKey = null ) } @@ -64,8 +65,8 @@ class MediaStorePagingSource( val songsMap = songs.associateBy { it.id.toLong() } val orderedSongs = idsToLoad.mapNotNull { songsMap[it] } - val nextKey = if (end < filteredIds.size) pageIndex + 1 else null - val prevKey = if (pageIndex > 0) pageIndex - 1 else null + val nextKey = if (end < filteredIds.size) end else null + val prevKey = if (start > 0) (start - params.loadSize).coerceAtLeast(0) else null LoadResult.Page( data = orderedSongs, @@ -75,7 +76,7 @@ class MediaStorePagingSource( } catch (e: Exception) { Timber.tag(TAG).e( e, - "Failed loading page (pageIndex=${params.key ?: 0}, loadSize=${params.loadSize}, totalIds=${filteredIds.size})" + "Failed loading page (start=$start, loadSize=${params.loadSize}, totalIds=${filteredIds.size})" ) LoadResult.Error(e) } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/PlaylistPreferencesRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/PlaylistPreferencesRepository.kt index aba3314..ad99f73 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/PlaylistPreferencesRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/PlaylistPreferencesRepository.kt @@ -80,7 +80,8 @@ class PlaylistPreferencesRepository @Inject constructor( suspend fun deletePlaylist(playlistId: String) { ensureMigratedIfNeeded() - localPlaylistDao.deletePlaylist(playlistId) + // Clear the playlist's song rows in the same transaction so they aren't orphaned (F73). + localPlaylistDao.deletePlaylistWithSongs(playlistId) clearPlaylistSongOrderMode(playlistId) } 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 8075e39..95574b5 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 @@ -1,11 +1,13 @@ package com.lostf1sh.pixelplayeross.data.preferences import android.content.Context +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.intPreferencesKey // Added import import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -18,7 +20,6 @@ import com.lostf1sh.pixelplayeross.data.model.SortOption // Added import import com.lostf1sh.pixelplayeross.data.model.FolderSource import com.lostf1sh.pixelplayeross.data.model.LyricsSourcePreference import com.lostf1sh.pixelplayeross.data.model.TransitionSettings -import com.lostf1sh.pixelplayeross.data.equalizer.EqualizerPreset // Added import import com.lostf1sh.pixelplayeross.data.model.StorageFilter import javax.inject.Inject import javax.inject.Singleton @@ -31,8 +32,15 @@ import kotlinx.coroutines.flow.map import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import timber.log.Timber -val Context.dataStore: DataStore by preferencesDataStore(name = "settings") +val Context.dataStore: DataStore by preferencesDataStore( + name = "settings", + // If the backing settings file is unreadable/corrupt (unclean shutdown, disk error, + // partial Platform Auto Backup restore), fall back to empty preferences instead of + // propagating an IOException through every preference flow and crashing on launch. + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() } +) object ThemePreference { const val DEFAULT = "default" @@ -68,6 +76,200 @@ enum class AlbumArtQuality(val maxSize: Int, val label: String) { ORIGINAL(0, "Original - Maximum quality") } +/** + * Expected DataStore value type for a known preference key. Used by backup import to validate the + * backup-supplied [PreferenceBackupEntry.type] against what live code actually reads a key as, so a + * crafted/old backup cannot write a key under a different type than the app expects (which would + * throw ClassCastException on the next typed read). + */ +enum class PrefType { + STRING, + INT, + LONG, + BOOLEAN, + FLOAT, + DOUBLE, + STRING_SET +} + +/** + * Per-key expected-type registry covering all DataStore keys owned by [UserPreferencesRepository], + * [EqualizerPreferencesRepository] and [ThemePreferencesRepository] (they share one DataStore). + * + * This is the single source of truth for backup type validation. A key NOT present here is treated + * as unknown and is restored on trust (the backup-supplied type wins) so that legitimately-restored + * keys we forgot to register, or keys added by newer app versions, are never silently dropped on an + * older build — i.e. the registry only *repairs/skips* known type mismatches, it never rejects + * unknown keys. + */ +internal object PreferenceTypeRegistry { + + val expectedTypes: Map = buildMap { + // --- UserPreferencesRepository keys --- + put("app_rebrand_dialog_shown", PrefType.BOOLEAN) + put("beta_05_clean_install_disclaimer_dismissed", PrefType.BOOLEAN) + put("allowed_directories", PrefType.STRING_SET) + put("blocked_directories", PrefType.STRING_SET) + put("initial_setup_done", PrefType.BOOLEAN) + put("player_theme_preference_v2", PrefType.STRING) + put("album_art_palette_style_v1", PrefType.STRING) + put("app_theme_mode", PrefType.STRING) + put("favorite_song_ids", PrefType.STRING_SET) + put("user_playlists_json_v1", PrefType.STRING) + put("playlist_song_order_modes", PrefType.STRING) + put("songs_sort_option", PrefType.STRING) + put("songs_sort_option_migrated_v2", PrefType.BOOLEAN) + put("albums_sort_option", PrefType.STRING) + put("artists_sort_option", PrefType.STRING) + put("playlists_sort_option", PrefType.STRING) + put("folders_sort_option", PrefType.STRING) + put("liked_songs_sort_option", PrefType.STRING) + put("last_library_tab_index", PrefType.INT) + put("last_storage_filter", PrefType.STRING) + put("mock_genres_enabled", PrefType.BOOLEAN) + put("last_daily_mix_update", PrefType.LONG) + put("daily_mix_song_ids", PrefType.STRING) + put("your_mix_song_ids", PrefType.STRING) + put("nav_bar_corner_radius", PrefType.INT) + put("nav_bar_style", PrefType.STRING) + put("nav_bar_compact_mode", PrefType.BOOLEAN) + put("carousel_style", PrefType.STRING) + put("library_navigation_mode", PrefType.STRING) + put("launch_tab", PrefType.STRING) + put("global_transition_settings_json", PrefType.STRING) + put("library_tabs_order", PrefType.STRING) + put("is_folder_filter_active", PrefType.BOOLEAN) + put("is_folders_playlist_view", PrefType.BOOLEAN) + put("hide_local_media", PrefType.BOOLEAN) + put("folders_source", PrefType.STRING) + put("folder_back_gesture_navigation", PrefType.BOOLEAN) + put("use_smooth_corners", PrefType.BOOLEAN) + put("keep_playing_in_background", PrefType.BOOLEAN) + put("is_crossfade_enabled", PrefType.BOOLEAN) + put("hi_fi_mode_enabled", PrefType.BOOLEAN) + put("crossfade_duration", PrefType.INT) + put("playback_speed", PrefType.FLOAT) + put("custom_genres", PrefType.STRING_SET) + put("custom_genre_icons", PrefType.STRING) + put("repeat_mode", PrefType.INT) + put("is_shuffle_on", PrefType.BOOLEAN) + put("persistent_shuffle_enabled", PrefType.BOOLEAN) + put("resume_on_headset_reconnect", PrefType.BOOLEAN) + put("show_queue_history", PrefType.BOOLEAN) + put("playback_queue_snapshot_v1", PrefType.STRING) + put("full_player_show_file_info", PrefType.BOOLEAN) + put("full_player_delay_all", PrefType.BOOLEAN) + put("full_player_delay_album", PrefType.BOOLEAN) + put("full_player_delay_metadata", PrefType.BOOLEAN) + put("full_player_delay_progress", PrefType.BOOLEAN) + put("full_player_delay_controls", PrefType.BOOLEAN) + put("full_player_placeholders", PrefType.BOOLEAN) + put("full_player_placeholder_transparent", PrefType.BOOLEAN) + put("full_player_placeholders_on_close", PrefType.BOOLEAN) + put("full_player_switch_on_drag_release", PrefType.BOOLEAN) + put("full_player_delay_threshold_percent", PrefType.INT) + put("full_player_close_threshold_percent", PrefType.INT) + put("use_player_sheet_v2", PrefType.BOOLEAN) + put("artist_delimiters", PrefType.STRING) + put("artist_word_delimiters", PrefType.STRING) + put("extract_artists_from_title", PrefType.BOOLEAN) + put("group_by_album_artist", PrefType.BOOLEAN) + put("artist_settings_rescan_required", PrefType.BOOLEAN) + put("backup_info_dismissed", PrefType.BOOLEAN) + put("last_sync_timestamp", PrefType.LONG) + put("directory_rules_version", PrefType.INT) + put("last_applied_directory_rules_version", PrefType.INT) + put("lyrics_sync_offsets_json", PrefType.STRING) + put("lyrics_source_preference", PrefType.STRING) + put("auto_scan_lrc_files", PrefType.BOOLEAN) + put("external_lyrics_enabled", PrefType.BOOLEAN) + put("external_artist_images_enabled", PrefType.BOOLEAN) + put("album_art_quality", PrefType.STRING) + put("album_art_cache_limit_mb", PrefType.INT) + put("tap_background_closes_player", PrefType.BOOLEAN) + put("haptics_enabled", PrefType.BOOLEAN) + put("immersive_lyrics_enabled", PrefType.BOOLEAN) + put("immersive_lyrics_timeout", PrefType.LONG) + put("use_animated_lyrics", PrefType.BOOLEAN) + put("animated_lyrics_blur_enabled", PrefType.BOOLEAN) + put("animated_lyrics_blur_strength", PrefType.FLOAT) + put("is_genre_grid_view", PrefType.BOOLEAN) + put("is_albums_list_view", PrefType.BOOLEAN) + put("collage_pattern", PrefType.STRING) + put("collage_auto_rotate", PrefType.BOOLEAN) + put("last_playlist_id", PrefType.STRING) + put("last_playlist_name", PrefType.STRING) + put("min_song_duration_ms", PrefType.INT) + put("min_tracks_per_album", PrefType.INT) + put("replaygain_enabled", PrefType.BOOLEAN) + put("replaygain_use_album_gain", PrefType.BOOLEAN) + + // --- EqualizerPreferencesRepository keys --- + put("equalizer_enabled", PrefType.BOOLEAN) + put("equalizer_preset", PrefType.STRING) + put("equalizer_custom_bands", PrefType.STRING) + put("bass_boost_strength", PrefType.INT) + put("virtualizer_strength", PrefType.INT) + put("bass_boost_enabled", PrefType.BOOLEAN) + put("virtualizer_enabled", PrefType.BOOLEAN) + put("loudness_enhancer_enabled", PrefType.BOOLEAN) + put("loudness_enhancer_strength", PrefType.INT) + put("bass_boost_dismissed", PrefType.BOOLEAN) + put("virtualizer_dismissed", PrefType.BOOLEAN) + put("loudness_dismissed", PrefType.BOOLEAN) + put("equalizer_view_mode", PrefType.STRING) + put("custom_presets_json", PrefType.STRING) + put("pinned_presets_json", PrefType.STRING) + put("is_graph_view", PrefType.BOOLEAN) // legacy fallback key still read by EqualizerPreferencesRepository + + // --- ThemePreferencesRepository keys (player_theme_preference_v2 / album_art_palette_style_v1 + // / app_theme_mode already registered above; only the unique one is added here) --- + put("album_art_color_accuracy_v1", PrefType.INT) + } + + /** The backup-entry `type` discriminator string corresponding to each [PrefType]. */ + fun wireName(type: PrefType): String = when (type) { + PrefType.STRING -> "string" + PrefType.INT -> "int" + PrefType.LONG -> "long" + PrefType.BOOLEAN -> "boolean" + PrefType.FLOAT -> "float" + PrefType.DOUBLE -> "double" + PrefType.STRING_SET -> "string_set" + } +} + +/** + * Keys that hold secrets / device-specific endpoints which must be redacted from a backup export. + * Matched by substring (case-insensitive) so future token/secret/password/URL keys are caught + * automatically. This is an opt-in redaction filter layered on top of [backupExcludedKeyNames]. + */ +private val SECRET_KEY_SUBSTRINGS = listOf("token", "secret", "password", "url") + +private fun isLikelySecretKey(keyName: String): Boolean { + val lower = keyName.lowercase() + return SECRET_KEY_SUBSTRINGS.any { lower.contains(it) } +} + +// Narrowing guards for backup restore: reject values that would overflow the target type or lose +// integrality (e.g. a tampered/corrupt backup carrying 1e300 or 3.7 under an int key), returning +// null so the caller skips the entry instead of silently writing a truncated/saturated value. +private fun Double.toIntExactPrefOrNull(): Int? = + takeIf { it.isFinite() && it >= Int.MIN_VALUE.toDouble() && it <= Int.MAX_VALUE.toDouble() && it % 1.0 == 0.0 } + ?.toInt() + +private fun Double.toLongExactPrefOrNull(): Long? = + takeIf { it.isFinite() && it >= Long.MIN_VALUE.toDouble() && it <= Long.MAX_VALUE.toDouble() && it % 1.0 == 0.0 } + ?.toLong() + +private fun Long.toIntRangedPrefOrNull(): Int? = + takeIf { it in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() }?.toInt() + +private fun Double.toFloatFinitePrefOrNull(): Float? { + if (!isFinite()) return null + return toFloat().takeIf { it.isFinite() } +} + @Singleton class UserPreferencesRepository @Inject @@ -83,6 +285,19 @@ constructor( "lastfm_session_key" ) + // Transient/derived runtime state that is not really a user "setting". A restore that wipes + // existing prefs must NOT clear these: doing so drops the resume-playback queue and resets + // the directory-rules/sync version markers, forcing a surprising full library re-sync. They + // are still imported normally if the backup actually carries them; this set only protects + // them from the clear step when the backup omits them. + private val restorePreservedKeyNames = setOf( + PreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT.name, + PreferencesKeys.LAST_SYNC_TIMESTAMP.name, + PreferencesKeys.DIRECTORY_RULES_VERSION.name, + PreferencesKeys.LAST_APPLIED_DIRECTORY_RULES_VERSION.name, + PreferencesKeys.LAST_DAILY_MIX_UPDATE.name + ) + private object PreferencesKeys { val APP_REBRAND_DIALOG_SHOWN = booleanPreferencesKey("app_rebrand_dialog_shown") val BETA_05_CLEAN_INSTALL_DISCLAIMER_DISMISSED = @@ -168,31 +383,15 @@ constructor( val ARTIST_SETTINGS_RESCAN_REQUIRED = booleanPreferencesKey("artist_settings_rescan_required") - // Equalizer Settings - val EQUALIZER_ENABLED = booleanPreferencesKey("equalizer_enabled") - val EQUALIZER_PRESET = stringPreferencesKey("equalizer_preset") - val EQUALIZER_CUSTOM_BANDS = stringPreferencesKey("equalizer_custom_bands") - val BASS_BOOST_STRENGTH = intPreferencesKey("bass_boost_strength") - val VIRTUALIZER_STRENGTH = intPreferencesKey("virtualizer_strength") - val BASS_BOOST_ENABLED = booleanPreferencesKey("bass_boost_enabled") - val VIRTUALIZER_ENABLED = booleanPreferencesKey("virtualizer_enabled") - val LOUDNESS_ENHANCER_ENABLED = booleanPreferencesKey("loudness_enhancer_enabled") - val LOUDNESS_ENHANCER_STRENGTH = intPreferencesKey("loudness_enhancer_strength") + // Equalizer settings (enabled/preset/custom bands, bass boost, virtualizer, loudness + // enhancer, dismissed-warning flags, view mode, custom/pinned presets) are owned solely + // by EqualizerPreferencesRepository over the same DataStore. Their key declarations used + // to be duplicated here but were dead (no flow/setter referenced them); they were removed + // to keep EqualizerPreferencesRepository the single owner of those key names. // Dismissed Warning States - val BASS_BOOST_DISMISSED = booleanPreferencesKey("bass_boost_dismissed") - val VIRTUALIZER_DISMISSED = booleanPreferencesKey("virtualizer_dismissed") - val LOUDNESS_DISMISSED = booleanPreferencesKey("loudness_dismissed") val BACKUP_INFO_DISMISSED = booleanPreferencesKey("backup_info_dismissed") - // View Mode - // val IS_GRAPH_VIEW = booleanPreferencesKey("is_graph_view") // Deprecated - val VIEW_MODE = stringPreferencesKey("equalizer_view_mode") - - // Custom Presets - val CUSTOM_PRESETS = stringPreferencesKey("custom_presets_json") // List - val PINNED_PRESETS = stringPreferencesKey("pinned_presets_json") // List (names) - // Library Sync val LAST_SYNC_TIMESTAMP = longPreferencesKey("last_sync_timestamp") val DIRECTORY_RULES_VERSION = intPreferencesKey("directory_rules_version") @@ -1627,7 +1826,7 @@ constructor( } suspend fun clearPreferencesExceptKeys(excludedKeyNames: Set) { - val protectedKeyNames = excludedKeyNames + backupExcludedKeyNames + val protectedKeyNames = excludedKeyNames + backupExcludedKeyNames + restorePreservedKeyNames dataStore.edit { preferences -> preferences.asMap().keys .filterNot { key -> key.name in protectedKeyNames } @@ -1645,6 +1844,12 @@ constructor( if (keyName in backupExcludedKeyNames) { return@mapNotNull null } + // Redact secret/endpoint-bearing keys (token/secret/password/url) from exports. None of + // the registered keys above match these substrings, so legitimate settings are kept; + // this only guards against a future credential key leaking into a shareable backup file. + if (isLikelySecretKey(keyName)) { + return@mapNotNull null + } when (value) { is String -> PreferenceBackupEntry( key = keyName, @@ -1695,8 +1900,11 @@ constructor( ) { dataStore.edit { preferences -> if (clearExisting) { + val protectedKeyNames = backupExcludedKeyNames + restorePreservedKeyNames preferences.asMap().keys - .filterNot { key -> key.name in backupExcludedKeyNames } + // Also preserve secret/endpoint keys: export redacts them, so a restore must not + // wipe live credentials that the backup deliberately omitted. + .filterNot { key -> key.name in protectedKeyNames || isLikelySecretKey(key.name) } .forEach { key -> @Suppress("UNCHECKED_CAST") preferences.remove(key as Preferences.Key) @@ -1707,21 +1915,44 @@ constructor( if (entry.key in backupExcludedKeyNames) { return@forEach } - when (entry.type) { + + // Symmetric with export's redaction: never restore secret/endpoint keys from a backup. + if (isLikelySecretKey(entry.key)) { + return@forEach + } + + // Per-key type validation: for any key we know the live type of, ignore the + // backup-supplied `entry.type` and write under the EXPECTED type. This repairs an + // entry whose declared type drifted from what the app reads it as (which would + // otherwise throw ClassCastException on the next typed read). Numeric expected + // types can repair across the other numeric carriers; a fundamentally + // incompatible value (e.g. string expected but only a boolean supplied) is skipped. + // Keys NOT in the registry fall back to the backup-supplied type so newer/unknown + // keys are still restored. + val expectedType = PreferenceTypeRegistry.expectedTypes[entry.key] + val effectiveType = expectedType?.let { PreferenceTypeRegistry.wireName(it) } ?: entry.type + if (expectedType != null && entry.type != effectiveType) { + Timber.w( + "Backup entry '%s' declared type '%s' but expected '%s'; coercing to expected type", + entry.key, entry.type, effectiveType + ) + } + + when (effectiveType) { "string" -> { val value = entry.stringValue ?: return@forEach preferences[stringPreferencesKey(entry.key)] = value } "int" -> { val value = entry.intValue - ?: entry.doubleValue?.toInt() - ?: entry.longValue?.toInt() + ?: entry.doubleValue?.toIntExactPrefOrNull() + ?: entry.longValue?.toIntRangedPrefOrNull() ?: return@forEach preferences[intPreferencesKey(entry.key)] = value } "long" -> { val value = entry.longValue - ?: entry.doubleValue?.toLong() + ?: entry.doubleValue?.toLongExactPrefOrNull() ?: entry.intValue?.toLong() ?: return@forEach preferences[longPreferencesKey(entry.key)] = value @@ -1732,13 +1963,17 @@ constructor( } "float" -> { val value = entry.floatValue - ?: entry.doubleValue?.toFloat() + ?: entry.doubleValue?.toFloatFinitePrefOrNull() + ?: entry.intValue?.toFloat() + ?: entry.longValue?.toFloat() ?: return@forEach preferences[androidx.datastore.preferences.core.floatPreferencesKey(entry.key)] = value } "double" -> { val value = entry.doubleValue ?: entry.floatValue?.toDouble() + ?: entry.intValue?.toDouble() + ?: entry.longValue?.toDouble() ?: return@forEach preferences[androidx.datastore.preferences.core.doublePreferencesKey(entry.key)] = value } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt index d50364a..65b9ec2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt @@ -118,6 +118,15 @@ class MusicRepositoryImpl @Inject constructor( /** Cached directory filter — recomputed only when allowed/blocked dirs preferences change. */ data class CachedDirFilter(val allowedParentDirs: List = emptyList(), val applyFilter: Boolean = false) + /** + * Holds the most recent real [CachedDirFilter] the instant [cachedDirFilter] computes one — set + * in onEach, before the StateFlow's value is updated. Once non-null it is always a real value, + * never the seeded default (applyFilter = false). This replaces a volatile Boolean flag whose + * "resolved" state could be observed while [cachedDirFilter] still held the default — a race + * that would bypass the directory filter and leak blocked-directory songs on a cold read. + */ + @Volatile private var lastResolvedDirFilter: CachedDirFilter? = null + private val cachedDirFilter: StateFlow = combine( userPreferencesRepository.allowedDirectoriesFlow, userPreferencesRepository.blockedDirectoriesFlow @@ -129,7 +138,22 @@ class MusicRepositoryImpl @Inject constructor( normalizePath = ::normalizePath ) CachedDirFilter(dirs, apply) - }.stateIn(repositoryScope, SharingStarted.Eagerly, CachedDirFilter()) + }.onEach { lastResolvedDirFilter = it } + .stateIn(repositoryScope, SharingStarted.Eagerly, CachedDirFilter()) + + /** + * Returns a resolved directory filter for one-shot reads. If [cachedDirFilter] has not yet + * produced its first real value (cold-start window), computes a fresh filter from the + * preference flows instead of returning the seeded default — otherwise blocked-directory + * songs could leak into queues/library on the first access after process start. + */ + private suspend fun resolvedDirFilter(): CachedDirFilter { + lastResolvedDirFilter?.let { return it } + val allowedDirs = userPreferencesRepository.allowedDirectoriesFlow.first() + val blockedDirs = userPreferencesRepository.blockedDirectoriesFlow.first() + val (allowedParentDirs, applyFilter) = computeAllowedDirs(allowedDirs, blockedDirs) + return CachedDirFilter(allowedParentDirs, applyFilter) + } private fun List.missingImageCandidates(): List> = asSequence() @@ -249,7 +273,7 @@ class MusicRepositoryImpl @Inject constructor( sortOption: SortOption, storageFilter: StorageFilter ): List = withContext(Dispatchers.IO) { - val filter = cachedDirFilter.value + val filter = resolvedDirFilter() musicDao.getFavoriteSongsPage( allowedParentDirs = filter.allowedParentDirs, applyDirectoryFilter = filter.applyFilter, @@ -273,7 +297,7 @@ class MusicRepositoryImpl @Inject constructor( } override suspend fun getRandomSongs(limit: Int): List = withContext(Dispatchers.IO) { - val filter = cachedDirFilter.value + val filter = resolvedDirFilter() musicDao.getRandomSongs(limit, filter.allowedParentDirs, filter.applyFilter).map { it.toSong() } } @@ -283,7 +307,7 @@ class MusicRepositoryImpl @Inject constructor( sortOption: SortOption, storageFilter: StorageFilter ): List = withContext(Dispatchers.IO) { - val filter = cachedDirFilter.value + val filter = resolvedDirFilter() musicDao.getSongsPage( allowedParentDirs = filter.allowedParentDirs, applyDirectoryFilter = filter.applyFilter, @@ -301,7 +325,7 @@ class MusicRepositoryImpl @Inject constructor( storageFilter: StorageFilter, minTracks: Int ): List = withContext(Dispatchers.IO) { - val filter = cachedDirFilter.value + val filter = resolvedDirFilter() musicDao.getAlbumsPage( allowedParentDirs = filter.allowedParentDirs, applyDirectoryFilter = filter.applyFilter, @@ -319,7 +343,7 @@ class MusicRepositoryImpl @Inject constructor( sortOption: SortOption, storageFilter: StorageFilter ): List = withContext(Dispatchers.IO) { - val filter = cachedDirFilter.value + val filter = resolvedDirFilter() musicDao.getArtistsPage( allowedParentDirs = filter.allowedParentDirs, applyDirectoryFilter = filter.applyFilter, @@ -517,14 +541,40 @@ class MusicRepositoryImpl @Inject constructor( override fun searchAlbums(query: String, minTracks: Int): Flow> { if (query.isBlank()) return flowOf(emptyList()) - return musicDao.searchAlbums(query, emptyList(), false, minTracks).map { entities -> + return combine( + userPreferencesRepository.allowedDirectoriesFlow, + userPreferencesRepository.blockedDirectoriesFlow + ) { allowedDirs, blockedDirs -> + allowedDirs to blockedDirs + }.flatMapLatest { (allowedDirs, blockedDirs) -> + flow { + val (allowedParentDirs, applyDirectoryFilter) = + computeAllowedDirs(allowedDirs, blockedDirs) + emit( + musicDao.searchAlbums(query, allowedParentDirs, applyDirectoryFilter, minTracks) + ) + }.flatMapLatest { it } + }.map { entities -> entities.map { it.toAlbum() } }.flowOn(Dispatchers.IO) } override fun searchArtists(query: String): Flow> { if (query.isBlank()) return flowOf(emptyList()) - return musicDao.searchArtists(query, emptyList(), false).map { entities -> + return combine( + userPreferencesRepository.allowedDirectoriesFlow, + userPreferencesRepository.blockedDirectoriesFlow + ) { allowedDirs, blockedDirs -> + allowedDirs to blockedDirs + }.flatMapLatest { (allowedDirs, blockedDirs) -> + flow { + val (allowedParentDirs, applyDirectoryFilter) = + computeAllowedDirs(allowedDirs, blockedDirs) + emit( + musicDao.searchArtists(query, allowedParentDirs, applyDirectoryFilter) + ) + }.flatMapLatest { it } + }.map { entities -> entities.map { it.toArtist() } }.flowOn(Dispatchers.IO) } @@ -708,7 +758,7 @@ class MusicRepositoryImpl @Inject constructor( } override suspend fun getAllAlbumsOnce(storageFilter: StorageFilter, minTracks: Int): List = withContext(Dispatchers.IO) { - val filter = cachedDirFilter.value + val filter = resolvedDirFilter() musicDao.getAlbumsPage( allowedParentDirs = filter.allowedParentDirs, applyDirectoryFilter = filter.applyFilter, @@ -721,7 +771,7 @@ class MusicRepositoryImpl @Inject constructor( } override suspend fun getAllArtistsOnce(): List = withContext(Dispatchers.IO) { - val filter = cachedDirFilter.value + val filter = resolvedDirFilter() musicDao.getArtistsWithSongCountsFiltered( allowedParentDirs = filter.allowedParentDirs, applyDirectoryFilter = filter.applyFilter, @@ -931,7 +981,7 @@ class MusicRepositoryImpl @Inject constructor( sortOption: SortOption, storageFilter: com.lostf1sh.pixelplayeross.data.model.StorageFilter ): List = withContext(Dispatchers.IO) { - val filter = cachedDirFilter.value + val filter = resolvedDirFilter() musicDao.getSongIdsSorted( allowedParentDirs = filter.allowedParentDirs, applyDirectoryFilter = filter.applyFilter, @@ -944,7 +994,7 @@ class MusicRepositoryImpl @Inject constructor( sortOption: SortOption, storageFilter: com.lostf1sh.pixelplayeross.data.model.StorageFilter ): List = withContext(Dispatchers.IO) { - val filter = cachedDirFilter.value + val filter = resolvedDirFilter() musicDao.getFavoriteSongIdsSorted( allowedParentDirs = filter.allowedParentDirs, applyDirectoryFilter = filter.applyFilter, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/TransitionRepositoryImpl.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/TransitionRepositoryImpl.kt index 1bd5357..00849a4 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/TransitionRepositoryImpl.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/TransitionRepositoryImpl.kt @@ -72,7 +72,8 @@ class TransitionRepositoryImpl @Inject constructor( } override suspend fun saveRule(rule: TransitionRule) { - transitionDao.setRule(rule.toEntity()) + // upsertRule dedupes the default rule, which a plain @Upsert cannot because of NULL track ids (F76). + transitionDao.upsertRule(rule.toEntity()) } override suspend fun deleteRule(ruleId: Long) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/TrustedMediaItemsResolution.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/TrustedMediaItemsResolution.kt index 7cf7ace..09d33ae 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/TrustedMediaItemsResolution.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/service/TrustedMediaItemsResolution.kt @@ -7,14 +7,14 @@ internal data class TrustedMediaItemsResolution( val trustedArtworkGrantItems: List, ) -internal fun resolveMediaItemsWithTrustedArtworkGrants( +internal suspend fun resolveMediaItemsWithTrustedArtworkGrants( requestedItems: List, - trustedItemResolver: (String) -> MediaItem? + trustedItemResolver: suspend (String) -> MediaItem? ): TrustedMediaItemsResolution { val resolvedItems = ArrayList(requestedItems.size) val trustedArtworkGrantItems = ArrayList() - requestedItems.forEach { requestedItem -> + for (requestedItem in requestedItems) { val trustedItem = trustedItemResolver(requestedItem.mediaId) if (trustedItem != null) { resolvedItems += trustedItem diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/stats/PlaybackStatsRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/stats/PlaybackStatsRepository.kt index fa28c80..0de0357 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/stats/PlaybackStatsRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/stats/PlaybackStatsRepository.kt @@ -179,8 +179,12 @@ class PlaybackStatsRepository @Inject constructor( startTimestamp = start, endTimestamp = coercedTimestamp ) + // Derive the prune reference from wall-clock now, never from the (possibly future-dated) + // event end, so a single play recorded under a skewed/forward clock cannot purge the + // entire legitimate history in one write. + val pruneReference = min(System.currentTimeMillis(), sanitizedEvent.endMillis()) val writeSucceeded = updateEventsAtomically { events -> - val cutoff = sanitizedEvent.endMillis() - MAX_HISTORY_AGE_MS + val cutoff = pruneReference - MAX_HISTORY_AGE_MS if (cutoff > 0) { events.removeAll { it.endMillis() < cutoff } } @@ -507,10 +511,13 @@ class PlaybackStatsRepository @Inject constructor( } private fun readEvents(): List { - synchronized(fileLock) { cachedEvents }?.let { return it } - val raw = synchronized(fileLock) { readRawHistoryLocked() } - return parseEvents(raw).also { parsed -> - synchronized(fileLock) { if (cachedEvents == null) cachedEvents = parsed } + // Read, parse and cache under a single lock so a concurrent write (which nulls + // cachedEvents) cannot interleave between the parse and the cache store and leave an + // older snapshot cached. Parsing a single small JSON file under the lock is cheap. + return synchronized(fileLock) { + cachedEvents ?: parseEvents(readRawHistoryLocked()).also { parsed -> + cachedEvents = parsed + } } } @@ -818,28 +825,15 @@ class PlaybackStatsRepository @Inject constructor( private fun updateEventsAtomically( transform: (MutableList) -> MutableList ): Boolean { - repeat(MAX_FILE_UPDATE_RETRIES) { - val rawSnapshot = synchronized(fileLock) { readRawHistoryLocked() } + // Hold fileLock across the whole read-modify-write so the operation is genuinely atomic. + // The previous optimistic compare-and-swap left a fallback path that wrote unconditionally + // after 3 CAS failures (clobbering concurrent writes) and relied on byte-identical Gson + // round-trips for correctness. The transforms here are bounded in-memory list operations, + // so serializing them under the lock is cheap and removes the read/transform/write race. + return synchronized(fileLock) { + val rawSnapshot = readRawHistoryLocked() val updatedEvents = transform(parseEvents(rawSnapshot)) val payload = serializeEvents(updatedEvents) - - val writeSucceeded = synchronized(fileLock) { - val latestRaw = readRawHistoryLocked() - if (latestRaw != rawSnapshot) { - return@synchronized false - } - val result = writePayloadLocked(payload) - if (result) cachedEvents = null - result - } - if (writeSucceeded) { - return true - } - } - - val fallbackRawSnapshot = synchronized(fileLock) { readRawHistoryLocked() } - val payload = serializeEvents(transform(parseEvents(fallbackRawSnapshot))) - return synchronized(fileLock) { val result = writePayloadLocked(payload) if (result) cachedEvents = null result @@ -1095,7 +1089,6 @@ class PlaybackStatsRepository @Inject constructor( companion object { private const val DEFAULT_PLAYBACK_HISTORY_LIMIT = 500 private const val MAX_PLAYBACK_HISTORY_LIMIT = 5_000 - private const val MAX_FILE_UPDATE_RETRIES = 3 private const val UNKNOWN_ARTIST = "Unknown Artist" private val MAX_HISTORY_AGE_MS = TimeUnit.DAYS.toMillis(730) // Keep roughly two years of history private const val SEGMENT_JOIN_TOLERANCE_MS = 0L diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/NavidromeSyncWorker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/NavidromeSyncWorker.kt index 3c31b4f..8eaa94b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/NavidromeSyncWorker.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/NavidromeSyncWorker.kt @@ -2,7 +2,10 @@ package com.lostf1sh.pixelplayeross.data.worker import android.content.Context import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints import androidx.work.CoroutineWorker +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkerParameters import androidx.work.workDataOf @@ -10,6 +13,7 @@ import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeRepository import dagger.assisted.Assisted import dagger.assisted.AssistedInject import timber.log.Timber +import java.util.concurrent.TimeUnit @HiltWorker class NavidromeSyncWorker @AssistedInject constructor( @@ -48,8 +52,13 @@ class NavidromeSyncWorker @AssistedInject constructor( } Result.success() } catch (e: Exception) { - Timber.e(e, "NavidromeSyncWorker: Sync failed") - Result.failure(workDataOf(ERROR_MESSAGE to e.message)) + if (runAttemptCount < MAX_RETRY_ATTEMPTS) { + Timber.w(e, "NavidromeSyncWorker: Sync failed (attempt ${runAttemptCount + 1}), retrying") + Result.retry() + } else { + Timber.e(e, "NavidromeSyncWorker: Sync failed permanently after $runAttemptCount attempts") + Result.failure(workDataOf(ERROR_MESSAGE to e.message)) + } } } @@ -65,10 +74,23 @@ class NavidromeSyncWorker @AssistedInject constructor( const val PROGRESS_MESSAGE = "progress_message" const val ERROR_MESSAGE = "error_message" + private const val MAX_RETRY_ATTEMPTS = 3 + private const val BACKOFF_DELAY_SECONDS = 30L + + private val networkConstraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + fun startAllSync() = OneTimeWorkRequestBuilder() .setInputData(workDataOf(KEY_SYNC_TYPE to SYNC_TYPE_ALL)) + .setConstraints(networkConstraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + BACKOFF_DELAY_SECONDS, + TimeUnit.SECONDS + ) .build() - + fun startPlaylistSync(playlistId: String) = OneTimeWorkRequestBuilder() .setInputData( workDataOf( @@ -76,6 +98,12 @@ class NavidromeSyncWorker @AssistedInject constructor( KEY_PLAYLIST_ID to playlistId ) ) + .setConstraints(networkConstraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + BACKOFF_DELAY_SECONDS, + TimeUnit.SECONDS + ) .build() } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncManager.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncManager.kt index 8c3a638..bafdc5b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncManager.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncManager.kt @@ -72,6 +72,12 @@ class SyncManager @Inject constructor( private var lastForegroundSyncTime = 0L @Volatile private var currentSyncWorkId: UUID? = null + // Tracks the most recent work the user explicitly asked for (pull-to-refresh, full + // rescan, rebuild). Opportunistic runs (startup, storage-change, foreground catch-up) + // leave this untouched, so [syncFailed] only surfaces a toast for failures the user + // initiated. Unique work means at most one foreground sync is active at a time. + @Volatile + private var lastUserInitiatedSyncWorkId: UUID? = null // Exposes a simple Flow. val isSyncing: Flow = @@ -93,14 +99,21 @@ class SyncManager @Inject constructor( ) /** - * Emits once each time the library sync finishes in a FAILED state with no other - * run still active. Used to surface a one-shot "sync failed" message to the user, - * which the [syncProgress] flow alone cannot do (it silently reverts to idle). + * Emits once each time a *user-initiated* library sync finishes in a FAILED state with + * no other run still active. Used to surface a one-shot "sync failed" message to the + * user, which the [syncProgress] flow alone cannot do (it silently reverts to idle). + * + * Opportunistic background runs (startup sync, storage-change auto-sync, foreground + * catch-up) share [SyncWorker.WORK_NAME] but are intentionally excluded here: a + * transient failure on a sync the user never asked for must not raise a toast. Only + * work enqueued via [enqueueSyncWork] with `userInitiated = true` is eligible. */ val syncFailed: Flow = workManager.getWorkInfosForUniqueWorkFlow(SyncWorker.WORK_NAME) .map { workInfos -> - latestForegroundSyncWork(workInfos)?.state == WorkInfo.State.FAILED + val failedWork = latestForegroundSyncWork(workInfos) + ?.takeIf { it.state == WorkInfo.State.FAILED } + failedWork != null && failedWork.id == lastUserInitiatedSyncWorkId } .distinctUntilChanged() .filter { it } @@ -250,7 +263,8 @@ class SyncManager @Inject constructor( Timber.tag(TAG).i("Incremental sync requested - Scheduling incremental worker") enqueueSyncWork( request = SyncWorker.incrementalSyncWork(runMaintenance = false), - policy = ExistingWorkPolicy.REPLACE + policy = ExistingWorkPolicy.REPLACE, + userInitiated = true ) } @@ -262,7 +276,8 @@ class SyncManager @Inject constructor( Timber.tag(TAG).i("Full sync requested - Scheduling full sync worker") enqueueSyncWork( request = SyncWorker.fullSyncWork(deepScan = deepScan), - policy = ExistingWorkPolicy.REPLACE + policy = ExistingWorkPolicy.REPLACE, + userInitiated = true ) } @@ -275,7 +290,8 @@ class SyncManager @Inject constructor( Timber.tag(TAG).i("Rebuild database requested - Scheduling rebuild worker") enqueueSyncWork( request = SyncWorker.rebuildDatabaseWork(), - policy = ExistingWorkPolicy.REPLACE + policy = ExistingWorkPolicy.REPLACE, + userInitiated = true ) } @@ -288,7 +304,8 @@ class SyncManager @Inject constructor( Timber.tag(TAG).i("Manual local refresh requested - Scheduling incremental worker") enqueueSyncWork( request = SyncWorker.incrementalSyncWork(runMaintenance = false), - policy = ExistingWorkPolicy.REPLACE + policy = ExistingWorkPolicy.REPLACE, + userInitiated = true ) } @@ -377,9 +394,13 @@ class SyncManager @Inject constructor( private fun enqueueSyncWork( request: OneTimeWorkRequest, policy: ExistingWorkPolicy, - notifyObserver: Boolean = true + notifyObserver: Boolean = true, + userInitiated: Boolean = false ) { currentSyncWorkId = request.id + if (userInitiated) { + lastUserInitiatedSyncWorkId = request.id + } workManager.enqueueUniqueWork( SyncWorker.WORK_NAME, policy, 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 bef1c82..c115486 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 @@ -8,7 +8,10 @@ import android.os.Trace // Import Trace import android.provider.MediaStore import androidx.hilt.work.HiltWorker import androidx.work.Constraints +import android.content.pm.ServiceInfo +import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequest @@ -23,6 +26,8 @@ import com.lostf1sh.pixelplayeross.data.database.SongEntity import com.lostf1sh.pixelplayeross.data.database.SourceType import com.lostf1sh.pixelplayeross.data.database.serializeArtistRefs import com.lostf1sh.pixelplayeross.data.model.ArtistRef +import com.lostf1sh.pixelplayeross.PixelPlayerApplication +import com.lostf1sh.pixelplayeross.R import com.lostf1sh.pixelplayeross.data.media.AudioMetadataReader import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository @@ -104,6 +109,18 @@ constructor( Timber.tag(TAG) .i("Starting MediaStore synchronization (Mode: $syncMode, ForceMetadata: $requestedForceMetadata)...") + + // Promote long full rescans / rebuilds to a foreground service so Android does not + // kill them when the app is backgrounded (F138). Best-effort: if the app cannot start + // an FGS (background restrictions), keep running as ordinary background work. + if (syncMode == SyncMode.FULL || syncMode == SyncMode.REBUILD) { + try { + setForeground(buildSyncForegroundInfo()) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Could not promote SyncWorker to foreground") + } + } + val startTime = System.currentTimeMillis() val artistDelimiters = userPreferencesRepository.artistDelimitersFlow.first() @@ -138,32 +155,33 @@ constructor( "(current=$directoryRulesVersion, applied=$lastAppliedDirectoryRulesVersion)" ) - // --- DELETION PHASE --- - // Detect and remove deleted songs efficiently using ID comparison - // We do this for INCREMENTAL and FULL modes. REBUILD clears everything anyway. - if (syncMode != SyncMode.REBUILD) { - // Only compare MediaStore-backed songs; cloud sources are excluded. - val localSongIds = musicDao.getAllMediaStoreSongIds().toHashSet() - val mediaStoreIds = fetchMediaStoreIds(directoryResolver) - - // Identify IDs that are in local DB but not in MediaStore - val deletedIds = localSongIds - mediaStoreIds - - if (deletedIds.isNotEmpty()) { - Timber.tag(TAG) - .i("Found ${deletedIds.size} deleted songs. Removing from database...") - setProgress( - workDataOf( - PROGRESS_CURRENT to 0, - PROGRESS_TOTAL to deletedIds.size, - PROGRESS_PHASE to SyncProgress.SyncPhase.SAVING_TO_DATABASE.ordinal - ) - ) - musicDao.deleteSongsAndRelatedData(deletedIds.toList()) + // --- DELETION DETECTION PHASE --- + // Detect deleted songs efficiently using ID comparison. We do this for + // INCREMENTAL and FULL modes. REBUILD clears everything anyway. + // + // NOTE: detection only — the actual removal is deferred and folded into the + // same @Transaction as the upsert below (incrementalSyncMusicData accepts a + // deletedSongIds parameter). Deleting here in a separate transaction risked a + // failure between the committed delete and the upsert, leaving rows deleted but + // their replacements unwritten until the next successful sync. + val deletedIds: List = + if (syncMode != SyncMode.REBUILD) { + // Only compare MediaStore-backed songs; cloud sources are excluded. + val localSongIds = musicDao.getAllMediaStoreSongIds().toHashSet() + val mediaStoreIds = fetchMediaStoreIds(directoryResolver) + + // Identify IDs that are in local DB but not in MediaStore + val ids = (localSongIds - mediaStoreIds).toList() + if (ids.isNotEmpty()) { + Timber.tag(TAG) + .i("Found ${ids.size} deleted songs. Removal deferred to upsert transaction...") + } else { + Timber.tag(TAG).d("No deleted songs found.") + } + ids } else { - Timber.tag(TAG).d("No deleted songs found.") + emptyList() } - } // --- FETCH PHASE --- // Determine what to fetch based on mode @@ -278,14 +296,15 @@ constructor( PROGRESS_PHASE to SyncProgress.SyncPhase.SAVING_TO_DATABASE.ordinal ) ) - // incrementalSyncMusicData handles upserts efficiently - // processing deleted songs was already handled at the start + // incrementalSyncMusicData applies the deletions and the upsert + // inside a single @Transaction, so an interrupted run can never + // leave songs deleted without their replacements being written. musicDao.incrementalSyncMusicData( songs = correctedSongs, albums = albums, artists = artists, crossRefs = crossRefs, - deletedSongIds = emptyList() // Already handled + deletedSongIds = deletedIds ) } } else if (syncMode == SyncMode.REBUILD) { @@ -302,6 +321,24 @@ constructor( artists = emptyList(), crossRefs = emptyList() ) + } else if (deletedIds.isNotEmpty()) { + // No new/modified songs to upsert, but there are deletions to apply. + // Run them through the same atomic path (its own @Transaction) instead + // of a standalone delete, keeping the deletion semantics consistent. + setProgress( + workDataOf( + PROGRESS_CURRENT to 0, + PROGRESS_TOTAL to deletedIds.size, + PROGRESS_PHASE to SyncProgress.SyncPhase.SAVING_TO_DATABASE.ordinal + ) + ) + musicDao.incrementalSyncMusicData( + songs = emptyList(), + albums = emptyList(), + artists = emptyList(), + crossRefs = emptyList(), + deletedSongIds = deletedIds + ) } if (rescanRequired) { @@ -741,6 +778,17 @@ constructor( existing.trackNumber == raw.trackNumber && existing.discNumber == raw.discNumber && existing.year == raw.year && + // Pick up external genre/album-artist edits that don't bump DATE_MODIFIED. + // Genre is only a change signal when the cursor actually supplied one + // (raw.genre != null, i.e. the API 30+ GENRE column); on older APIs genre + // is sourced from a separate query/cache and raw.genre is null, so skip it + // there to avoid spuriously reprocessing every song each incremental sync. + // User-edited genres are honored via genreUserEdited, like title/artist/album. + (existing.genreUserEdited || raw.genre == null || existing.genre == raw.genre) && + // Like genre: only a change signal when the cursor actually supplied an album artist. + // MediaStore's ALBUM_ARTIST is often null/blank, which would otherwise spuriously + // reprocess songs whose existing album artist was resolved from tag metadata. + (raw.albumArtist == null || existing.albumArtist == raw.albumArtist) && existingDateAddedSeconds == raw.dateAdded && existingDateModifiedSeconds == raw.dateModified } @@ -1148,8 +1196,32 @@ constructor( return ids } + override suspend fun getForegroundInfo(): ForegroundInfo = buildSyncForegroundInfo() + + private fun buildSyncForegroundInfo(): ForegroundInfo { + val notification = NotificationCompat.Builder( + applicationContext, + PixelPlayerApplication.SYNC_NOTIFICATION_CHANNEL_ID + ) + .setContentTitle(applicationContext.getString(R.string.sync_notification_title)) + .setSmallIcon(R.drawable.monochrome_player) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ForegroundInfo( + SYNC_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo(SYNC_NOTIFICATION_ID, notification) + } + } + companion object { const val WORK_NAME = "com.lostf1sh.pixelplayeross.data.worker.SyncWorker" + private const val SYNC_NOTIFICATION_ID = 47_001 // Distinct unique name so background maintenance never feeds the WORK_NAME-bound // isSyncing/syncProgress flows — the loading indicator stays silent for it. const val PERIODIC_MAINTENANCE_WORK_NAME = 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 bc75f93..9899b95 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt @@ -121,56 +121,18 @@ object AppModule { context.applicationContext, PixelPlayerDatabase::class.java, "pixelplayer_database" - ).addMigrations( - PixelPlayerDatabase.MIGRATION_3_4, - PixelPlayerDatabase.MIGRATION_4_5, - PixelPlayerDatabase.MIGRATION_5_6, - PixelPlayerDatabase.MIGRATION_6_7, - PixelPlayerDatabase.MIGRATION_7_8, - PixelPlayerDatabase.MIGRATION_8_9, - PixelPlayerDatabase.MIGRATION_9_10, - PixelPlayerDatabase.MIGRATION_10_11, - PixelPlayerDatabase.MIGRATION_11_12, - PixelPlayerDatabase.MIGRATION_12_13, - PixelPlayerDatabase.MIGRATION_13_14, - PixelPlayerDatabase.MIGRATION_14_15, - PixelPlayerDatabase.MIGRATION_15_16, - PixelPlayerDatabase.MIGRATION_16_17, - PixelPlayerDatabase.MIGRATION_17_18, - PixelPlayerDatabase.MIGRATION_18_19, - PixelPlayerDatabase.MIGRATION_19_20, - PixelPlayerDatabase.MIGRATION_20_21, - PixelPlayerDatabase.MIGRATION_21_22, - PixelPlayerDatabase.MIGRATION_22_23, - PixelPlayerDatabase.MIGRATION_23_24, - PixelPlayerDatabase.MIGRATION_24_25, - PixelPlayerDatabase.MIGRATION_25_26, - PixelPlayerDatabase.MIGRATION_26_27, - PixelPlayerDatabase.MIGRATION_27_28, - PixelPlayerDatabase.MIGRATION_28_29, - PixelPlayerDatabase.MIGRATION_29_30, - PixelPlayerDatabase.MIGRATION_30_31, - PixelPlayerDatabase.MIGRATION_31_32, - PixelPlayerDatabase.MIGRATION_32_33, - PixelPlayerDatabase.MIGRATION_33_34, - PixelPlayerDatabase.MIGRATION_34_35, - PixelPlayerDatabase.MIGRATION_35_36, - PixelPlayerDatabase.MIGRATION_36_37, - PixelPlayerDatabase.MIGRATION_37_38, - PixelPlayerDatabase.MIGRATION_38_39, - PixelPlayerDatabase.MIGRATION_39_40, - PixelPlayerDatabase.MIGRATION_40_41, - PixelPlayerDatabase.MIGRATION_41_42, - PixelPlayerDatabase.MIGRATION_42_43, - PixelPlayerDatabase.MIGRATION_43_44, - PixelPlayerDatabase.MIGRATION_44_45 ) .addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback()) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) - - // P2-4: Only allow destructive migration in debug builds. - // In release, a migration bug will crash the app (revealing the problem) - // rather than silently wiping user data (playlists, favorites, statistics). + // Schema starts at version 1 for the first public (F-Droid) release, so there are no + // upgrade migrations yet. If a throwaway pre-release build with a higher DB version is + // present, wipe-and-recreate on downgrade instead of crashing (no real data exists yet). + // Forward migrations are added normally from version 2 onward. + .fallbackToDestructiveMigrationOnDowngrade(dropAllTables = true) + + // P2-4: Only allow full destructive migration in debug builds. In release, a future + // migration bug should crash the app (revealing the problem) rather than silently wiping + // user data (playlists, favorites, statistics). if (BuildConfig.DEBUG) { builder.fallbackToDestructiveMigration(dropAllTables = true) } @@ -387,6 +349,11 @@ object AppModule { redactHeader("X-Emby-Token") redactHeader("X-Emby-Authorization") redactHeader("X-MediaBrowser-Token") + // Redact credential-bearing URL query params. HEADERS level still logs the + // request line (--> GET ), and Navidrome/Subsonic carries its session + // token+salt in the URL ("t"/"s"), while Jellyfin appends "api_key". Without + // this they would be written to logcat for every API/stream/cover-art request. + redactQueryParams("t", "s", "api_key") } // Connection pool with optimized connections for better performance 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 83e2cf6..ff5415d 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri @@ -623,8 +624,14 @@ private fun EditSongContent( val encodedTitle = URLEncoder.encode(title, "UTF-8") val encodedArtist = URLEncoder.encode(artist, "UTF-8") val url = "https://lrclib.net/search/$encodedTitle%20$encodedArtist" - val intent = Intent(Intent.ACTION_VIEW).setData(Uri.parse(url)) - context.startActivity(intent) + val intent = Intent(Intent.ACTION_VIEW) + .setData(Uri.parse(url)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.w(e, "No activity found to handle LRCLIB lyrics search URL") + } }, ) { Icon( diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ExpressiveOfflineState.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ExpressiveOfflineState.kt index a448031..2632462 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ExpressiveOfflineState.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ExpressiveOfflineState.kt @@ -43,8 +43,10 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.lostf1sh.pixelplayeross.R import com.lostf1sh.pixelplayeross.ui.theme.RoundedSans import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape @@ -52,10 +54,15 @@ import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape fun ExpressiveOfflineState( modifier: Modifier = Modifier, onRetry: () -> Unit, - message: String = "You're Offline", - description: String = "Please checking your internet connection to continue.", + message: String? = null, + description: String? = null, isDialog: Boolean = false ) { + // Default copy cannot be a default-parameter value because stringResource is @Composable, + // so resolve here in the composable body. + val resolvedMessage = message ?: stringResource(R.string.offline_title) + val resolvedDescription = description ?: stringResource(R.string.offline_description) + var isVisible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -124,7 +131,7 @@ fun ExpressiveOfflineState( Spacer(modifier = Modifier.height(24.dp)) Text( - text = message, + text = resolvedMessage, style = if (isDialog) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineMedium, fontFamily = RoundedSans, fontWeight = FontWeight.Bold, @@ -135,7 +142,7 @@ fun ExpressiveOfflineState( Spacer(modifier = Modifier.height(12.dp)) Text( - text = description, + text = resolvedDescription, style = MaterialTheme.typography.bodyLarge, fontFamily = RoundedSans, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -163,7 +170,7 @@ fun ExpressiveOfflineState( ) Spacer(modifier = Modifier.size(8.dp)) Text( - "Try Again", + stringResource(R.string.offline_retry), fontFamily = RoundedSans, fontWeight = FontWeight.SemiBold ) @@ -198,7 +205,7 @@ fun ExpressiveOfflineDialog( onDismiss() }, isDialog = true, - description = "You need an internet connection to play this uncached song." + description = stringResource(R.string.offline_uncached_song_description) ) } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ExpressiveScrollBar.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ExpressiveScrollBar.kt index 5b7032e..e4a59ca 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ExpressiveScrollBar.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ExpressiveScrollBar.kt @@ -266,6 +266,15 @@ fun ExpressiveScrollBar( val coarseJumpThresholdPx = with(density) { 16.dp.toPx() } val smoothJumpMinDistancePx = with(density) { 10.dp.toPx() } + // scrollableHeight depends only on the (stable) viewport height and handle height, + // so it is invariant per composition. The Canvas draw block and the offset{} layout + // lambdas only need this value; reading it directly avoids re-running the heavy + // getScrollStats() metrics computation (and its non-snapshot tracker mutations) from + // the draw/layout phases on every frame during scroll. + val scrollableHeightPx = with(density) { + (constraintsMaxHeight.toPx() - minHeight.toPx()).coerceAtLeast(1f) + } + val canScrollForward by remember { derivedStateOf { listState?.canScrollForward ?: gridState?.canScrollForward ?: false } } val canScrollBackward by remember { derivedStateOf { listState?.canScrollBackward ?: gridState?.canScrollBackward ?: false } } @@ -591,8 +600,7 @@ fun ExpressiveScrollBar( val trackX = rightAnchorX - with(density) { thickness.toPx() / 2 } Canvas(modifier = Modifier.fillMaxSize()) { - val stats = getScrollStats() - val scrollableHeight = stats.scrollableHeight + val scrollableHeight = scrollableHeightPx val visualProgress = displayedProgress.value val displayProgress = if (isDragging && dragProgress >= 0f) dragProgress else visualProgress @@ -652,8 +660,7 @@ fun ExpressiveScrollBar( Box( modifier = Modifier .offset { - val stats = getScrollStats() - val scrollableHeight = stats.scrollableHeight + val scrollableHeight = scrollableHeightPx val visualProgress = displayedProgress.value val displayProgress = if (isDragging && dragProgress >= 0f) dragProgress else visualProgress val handleY = displayProgress * scrollableHeight @@ -690,8 +697,7 @@ fun ExpressiveScrollBar( Surface( modifier = Modifier .offset { - val stats = getScrollStats() - val scrollableHeight = stats.scrollableHeight + val scrollableHeight = scrollableHeightPx val visualProgress = displayedProgress.value val displayProgress = if (isDragging && dragProgress >= 0f) dragProgress else visualProgress val handleY = displayProgress * scrollableHeight diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/FileExplorerBottomSheet.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/FileExplorerBottomSheet.kt index b524128..dbd962d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/FileExplorerBottomSheet.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/FileExplorerBottomSheet.kt @@ -82,6 +82,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextGeometricTransform import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import com.lostf1sh.pixelplayeross.R import androidx.compose.ui.unit.sp @@ -191,7 +192,7 @@ fun FileExplorerContent( onStorageSelected: (Int) -> Unit, onDone: () -> Unit, onDismiss: () -> Unit, - title: String = "Excluded folders", + title: String = stringResource(R.string.setup_excluded_folders_title), leadingContent: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier ) { @@ -214,16 +215,20 @@ fun FileExplorerContent( isLoading || isPriming || !isReady || !isCurrentDirectoryResolved ) } - val loadingMessage = remember(isPriming, isReady) { + // stringResource is @Composable, so resolve outside the remember{} lambdas and key on them. + val preparingFoldersText = stringResource(R.string.presentation_batch_g_file_explorer_loading_preparing) + val loadingFoldersText = stringResource(R.string.presentation_batch_g_file_explorer_loading) + val loadingFoldersHintText = stringResource(R.string.presentation_batch_g_file_explorer_loading_hint) + val loadingMessage = remember(isPriming, isReady, preparingFoldersText, loadingFoldersText) { if (isPriming || !isReady) { - "Preparing folders…" + preparingFoldersText } else { - "Loading folders…" + loadingFoldersText } } - val loadingHint = remember(isPriming, isReady) { + val loadingHint = remember(isPriming, isReady, loadingFoldersHintText) { if (isPriming || !isReady) { - "This can take a moment while PixelPlayerOSS scans the available subfolders." + loadingFoldersHintText } else { null } @@ -592,10 +597,13 @@ private fun FileExplorerItem( ) { Text( text = when { - audioCount < 0 -> "Scanning..." - audioCount == 1 -> "1 song" - audioCount > 99 -> "99+ songs" - else -> "$audioCount songs" + audioCount < 0 -> stringResource(R.string.presentation_batch_g_file_explorer_count_scanning) + audioCount > 99 -> stringResource(R.string.presentation_batch_g_file_explorer_count_99plus) + else -> pluralStringResource( + R.plurals.presentation_batch_g_file_explorer_song_count, + audioCount, + audioCount + ) }, style = MaterialTheme.typography.labelMedium, color = badgeColor, @@ -620,7 +628,7 @@ private fun FileExplorerItem( verticalArrangement = Arrangement.Center ) { Text( - text = if (isBlocked) "Excluded" else "Included", + text = if (isBlocked) stringResource(R.string.presentation_batch_g_file_explorer_badge_excluded) else stringResource(R.string.presentation_batch_g_file_explorer_badge_included), style = MaterialTheme.typography.labelMedium, color = if (isBlocked) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant ) @@ -664,9 +672,10 @@ private fun FileExplorerHeader( }) } - val rootLabel = remember(rootDirectory) { + val internalStorageLabel = stringResource(R.string.presentation_batch_g_file_explorer_internal_storage) + val rootLabel = remember(rootDirectory, internalStorageLabel) { when (rootDirectory.name) { - "0", "" -> "Internal storage" + "0", "" -> internalStorageLabel else -> rootDirectory.name } } 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 f65ca6e..d123eec 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 @@ -390,29 +390,6 @@ fun LyricsSheet( } } - DisposableEffect(keepScreenOn, lifecycleOwner) { - val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> - if (event == androidx.lifecycle.Lifecycle.Event.ON_STOP && keepScreenOn) { - keepScreenOn = false - coroutineScope.launch { - context.dataStore.edit { prefs -> - prefs[booleanPreferencesKey("keep_screen_on_lyrics")] = false - } - } - } - } - - if (keepScreenOn) { - view.keepScreenOn = true - } - - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - view.keepScreenOn = false - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - val resolvedAutoscrollSpec = autoscrollAnimationSpec ?: if (useAnimatedLyrics) { spring( stiffness = Spring.StiffnessMediumLow, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/PlaylistBottomSheet.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/PlaylistBottomSheet.kt index e576044..b9ae910 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/PlaylistBottomSheet.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/PlaylistBottomSheet.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf @@ -78,19 +79,19 @@ fun PlaylistBottomSheet( if (searchQuery.isBlank()) editablePlaylists else editablePlaylists.filter { it.name.contains(searchQuery, true) } } - val selectedPlaylists = remember { - mutableStateMapOf().apply { - if (songs.size == 1) { - // Single song: pre-select playlists containing it - val songId = songs.first().id - filteredPlaylists.forEach { - put(it.id, it.songIds.contains(songId)) - } - } else { - // Multiple songs: start empty (additive only) - filteredPlaylists.forEach { - put(it.id, false) - } + // Stable map across recompositions; PlaylistContainer mutates it on checkbox toggles. + val selectedPlaylists = remember { mutableStateMapOf() } + + // Reconcile entries when the playlist set changes (async load, in-sheet creation, + // or search filtering). Only seed playlist ids that have no entry yet, so existing + // user toggles are preserved, while newly appearing playlists get their initial + // (and, for the single-song case, "already contains this song") state evaluated. + LaunchedEffect(filteredPlaylists, songs) { + val singleSongId = songs.singleOrNull()?.id + filteredPlaylists.forEach { playlist -> + if (playlist.id !in selectedPlaylists) { + selectedPlaylists[playlist.id] = + singleSongId != null && playlist.songIds.contains(singleSongId) } } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ReorderPresetsSheet.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ReorderPresetsSheet.kt index 95a75be..4084091 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ReorderPresetsSheet.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ReorderPresetsSheet.kt @@ -52,6 +52,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -127,7 +128,15 @@ fun ReorderPresetsSheet( } var localItems by remember { mutableStateOf(initialList) } - + + // Re-seed the editable model when the source inputs change (e.g. after a save + // updates pinnedPresetsNames upstream, or a custom preset is added/removed), + // mirroring ReorderTabsSheet. Without this the first-captured list goes stale + // on reopen and a later save would silently revert the user's reorder. + LaunchedEffect(initialList) { + localItems = initialList + } + val scope = rememberCoroutineScope() val listState = rememberLazyListState() val reorderableState = rememberReorderableLazyListState( diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ReorderTabsSheet.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ReorderTabsSheet.kt index 9c2e85a..a8e88cb 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ReorderTabsSheet.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/ReorderTabsSheet.kt @@ -53,7 +53,6 @@ import androidx.compose.ui.unit.dp import androidx.core.view.ViewCompat import android.view.HapticFeedbackConstants import com.lostf1sh.pixelplayeross.R -import com.lostf1sh.pixelplayeross.presentation.library.LibraryTabId import com.lostf1sh.pixelplayeross.presentation.utils.LocalAppHapticsConfig import com.lostf1sh.pixelplayeross.presentation.utils.performAppCompatHapticFeedback import com.lostf1sh.pixelplayeross.ui.theme.RoundedSans 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 14f17f8..9bb86c2 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 @@ -431,38 +431,46 @@ fun SongInfoBottomSheet( ) } - FilledTonalIconButton( - modifier = Modifier - .weight(0.25f) - .fillMaxHeight(), - onClick = { - try { - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "audio/*" - putExtra(Intent.EXTRA_STREAM, song.contentUriString.toUri()) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity( - Intent.createChooser( - shareIntent, - context.getString(R.string.song_info_share_chooser_title) + // Cloud songs (Navidrome/Jellyfin) expose a + // navidrome:// / jellyfin:// contentUriString + // with no ContentProvider behind it, so EXTRA_STREAM + // would produce a broken, unresolvable attachment with + // no local error feedback. Only offer file-sharing for + // local songs. + if (!songLocationInfo.isCloud) { + FilledTonalIconButton( + modifier = Modifier + .weight(0.25f) + .fillMaxHeight(), + onClick = { + try { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "audio/*" + putExtra(Intent.EXTRA_STREAM, song.contentUriString.toUri()) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity( + Intent.createChooser( + shareIntent, + context.getString(R.string.song_info_share_chooser_title) + ) ) - ) - } catch (e: Exception) { - Toast.makeText( - context, - context.getString(R.string.error_share_song_format, e.localizedMessage ?: ""), - Toast.LENGTH_LONG - ).show() - } - }, - shape = CircleShape - ) { - Icon( - modifier = Modifier.size(FloatingActionButtonDefaults.LargeIconSize), - imageVector = Icons.Rounded.Share, - contentDescription = stringResource(R.string.cd_share_song_file) - ) + } catch (e: Exception) { + Toast.makeText( + context, + context.getString(R.string.error_share_song_format, e.localizedMessage ?: ""), + Toast.LENGTH_LONG + ).show() + } + }, + shape = CircleShape + ) { + Icon( + modifier = Modifier.size(FloatingActionButtonDefaults.LargeIconSize), + imageVector = Icons.Rounded.Share, + contentDescription = stringResource(R.string.cd_share_song_file) + ) + } } } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerSheetShared.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerSheetShared.kt index a817d4e..7f9bfa9 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerSheetShared.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/UnifiedPlayerSheetShared.kt @@ -130,8 +130,8 @@ internal fun MiniPlayerContentInternal( AutoScrollingText( text = when { - isOutputConnecting -> "Connecting to device…" - isPreparingPlayback -> "Preparing playback…" + isOutputConnecting -> stringResource(R.string.mini_player_connecting_to_device) + isPreparingPlayback -> stringResource(R.string.mini_player_preparing_playback) else -> song.title }, style = titleStyle, @@ -139,7 +139,7 @@ internal fun MiniPlayerContentInternal( canScroll = canScroll ) AutoScrollingText( - text = if (isPreparingPlayback) "Loading audio…" else song.displayArtist, + text = if (isPreparingPlayback) stringResource(R.string.mini_player_loading_audio) else song.displayArtist, style = artistStyle, gradientEdgeColor = LocalMaterialTheme.current.primaryContainer, canScroll = canScroll diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/external/ExternalPlayerOverlay.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/external/ExternalPlayerOverlay.kt index e6e1b7b..3049ff0 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/external/ExternalPlayerOverlay.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/external/ExternalPlayerOverlay.kt @@ -58,9 +58,18 @@ import com.lostf1sh.pixelplayeross.presentation.components.WavySliderExpressive import com.lostf1sh.pixelplayeross.presentation.components.player.AnimatedPlaybackControls import com.lostf1sh.pixelplayeross.presentation.viewmodel.PlayerViewModel import com.lostf1sh.pixelplayeross.utils.formatDuration +import kotlinx.coroutines.delay import kotlin.math.roundToLong import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape +/** + * Maximum time the overlay waits for [PlayerViewModel.playExternalUri] to resolve the incoming + * URI into a playable song. If resolution fails, [PlayerViewModel.playExternalUri] only emits a + * toast and never sets `currentSong`, which would otherwise leave this overlay showing an + * indefinite spinner. After this timeout we auto-dismiss so the surface cannot hang. + */ +private const val EXTERNAL_SONG_RESOLUTION_TIMEOUT_MS = 15_000L + @OptIn(UnstableApi::class) @Composable fun ExternalPlayerOverlay( @@ -108,6 +117,19 @@ fun ExternalPlayerOverlay( } } + // Guard against an indefinite spinner: if URI resolution fails, playExternalUri only emits a + // toast and never sets currentSong, so the awaiting state would never clear. Auto-dismiss after + // a bounded wait so the overlay cannot hang on a common, externally-triggered failure path. + LaunchedEffect(awaitingSong) { + if (awaitingSong) { + delay(EXTERNAL_SONG_RESOLUTION_TIMEOUT_MS) + if (awaitingSong && currentSong == null) { + sheetVisible = false + onDismiss() + } + } + } + BackHandler(enabled = sheetVisible) { onDismiss() } 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 63c790d..a58e6ad 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 @@ -42,7 +42,11 @@ internal class MiniPlayerDismissGestureHandler( private var accumulatedDragX: Float = 0f private var offsetJob: Job? = null + /** Whether a dismiss animation is currently running. */ + private var isDismissing: Boolean = false + fun onDragStart() { + if (isDismissing) return dragPhase = MiniDismissDragPhase.TENSION accumulatedDragX = 0f offsetJob?.cancel() @@ -52,6 +56,7 @@ internal class MiniPlayerDismissGestureHandler( } fun onHorizontalDrag(dragAmount: Float) { + if (isDismissing) return accumulatedDragX += dragAmount when (dragPhase) { @@ -103,22 +108,28 @@ internal class MiniPlayerDismissGestureHandler( } fun onDragEnd() { + if (isDismissing) return dragPhase = MiniDismissDragPhase.IDLE offsetJob?.cancel() val dismissThreshold = screenWidthPx * 0.4f if (abs(accumulatedDragX) > dismissThreshold) { + isDismissing = true 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 + try { + offsetAnimatable.animateTo( + targetValue = targetDismissOffset, + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ) ) - ) - onDismissPlaylistAndShowUndo() - offsetAnimatable.snapTo(0f) + onDismissPlaylistAndShowUndo() + offsetAnimatable.snapTo(0f) + } finally { + isDismissing = false + } } } else { offsetJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/SheetActionHandlers.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/SheetActionHandlers.kt index a96ffd1..854840c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/SheetActionHandlers.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/scoped/SheetActionHandlers.kt @@ -115,9 +115,8 @@ internal fun rememberSheetActionHandlers( queueSheetControllerState.value.animate(false) sheetModalOverlayControllerState.value.updateSelectedSongForInfo(null) if (!song.genre.isNullOrEmpty()) { - val encodedGenre = java.net.URLEncoder.encode(song.genre, "UTF-8") navController.navigateSafelyReplacing( - route = Screen.GenreDetail.createRoute(encodedGenre), + route = Screen.GenreDetail.createRoute(song.genre), patternToPop = Screen.GenreDetail.route ) } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/jellyfin/dashboard/JellyfinDashboardScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/jellyfin/dashboard/JellyfinDashboardScreen.kt index ef5b843..ecfd8e4 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/jellyfin/dashboard/JellyfinDashboardScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/jellyfin/dashboard/JellyfinDashboardScreen.kt @@ -49,7 +49,7 @@ fun JellyfinDashboardScreen( ) { val playlists by viewModel.playlists.collectAsStateWithLifecycle() val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() - val syncMessage by viewModel.syncMessage.collectAsStateWithLifecycle() + val syncBanner by viewModel.syncBanner.collectAsStateWithLifecycle() val cardShape = AbsoluteSmoothCornerShape( cornerRadiusTR = 20.dp, cornerRadiusTL = 20.dp, @@ -92,7 +92,7 @@ fun JellyfinDashboardScreen( JellyfinDashboardContent( playlists = playlists, isSyncing = isSyncing, - syncMessage = syncMessage, + syncBanner = syncBanner, username = viewModel.username, onSyncAll = { viewModel.syncAllPlaylistsAndSongs() }, onSyncPlaylist = { viewModel.syncPlaylistSongs(it) }, @@ -112,7 +112,7 @@ fun JellyfinDashboardScreen( private fun JellyfinDashboardContent( playlists: List, isSyncing: Boolean, - syncMessage: String?, + syncBanner: JellyfinSyncBanner?, username: String?, onSyncAll: () -> Unit, onSyncPlaylist: (String) -> Unit, @@ -129,24 +129,53 @@ private fun JellyfinDashboardContent( ) { // Sync status banner AnimatedVisibility( - visible = syncMessage != null, + visible = syncBanner != null, enter = slideInVertically( animationSpec = spring(stiffness = Spring.StiffnessMedium) ) + fadeIn(), exit = fadeOut() ) { - syncMessage?.let { message -> + syncBanner?.let { banner -> + val message = when (banner) { + JellyfinSyncBanner.SyncingAll -> stringResource(R.string.jellyfin_syncing_all) + JellyfinSyncBanner.SyncingPlaylists -> stringResource(R.string.jellyfin_syncing_playlists) + JellyfinSyncBanner.SyncingSongs -> stringResource(R.string.jellyfin_syncing_songs) + is JellyfinSyncBanner.SyncedSummary -> + if (banner.failedCount == 0) { + stringResource(R.string.jellyfin_synced_summary, banner.playlistCount, banner.songCount) + } else { + stringResource( + R.string.jellyfin_synced_summary_with_failures, + banner.playlistCount, + banner.songCount, + banner.failedCount + ) + } + is JellyfinSyncBanner.SyncedPlaylists -> + stringResource(R.string.jellyfin_synced_playlists, banner.count) + is JellyfinSyncBanner.SyncedSongs -> + stringResource(R.string.jellyfin_synced_songs, banner.count) + is JellyfinSyncBanner.Failed -> { + val reasonRes = when (banner.reason) { + JellyfinSyncErrorReason.Network -> R.string.jellyfin_sync_error_network + JellyfinSyncErrorReason.Auth -> R.string.jellyfin_sync_error_auth + JellyfinSyncErrorReason.ServerUnavailable -> R.string.jellyfin_sync_error_server + JellyfinSyncErrorReason.Unknown -> R.string.jellyfin_sync_error_unknown + } + stringResource(R.string.jellyfin_sync_failed, stringResource(reasonRes)) + } + } + val containerColor = when (banner.status) { + JellyfinSyncStatus.Error -> MaterialTheme.colorScheme.errorContainer + JellyfinSyncStatus.Partial -> MaterialTheme.colorScheme.tertiaryContainer + else -> MaterialTheme.colorScheme.primaryContainer + } Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), shape = cardShape, - colors = CardDefaults.cardColors( - containerColor = if (message.contains("failed")) - MaterialTheme.colorScheme.errorContainer - else - MaterialTheme.colorScheme.primaryContainer - ) + colors = CardDefaults.cardColors(containerColor = containerColor) ) { Row( modifier = Modifier.padding(16.dp), diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/jellyfin/dashboard/JellyfinDashboardViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/jellyfin/dashboard/JellyfinDashboardViewModel.kt index f21750f..a5f419e 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/jellyfin/dashboard/JellyfinDashboardViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/jellyfin/dashboard/JellyfinDashboardViewModel.kt @@ -6,6 +6,7 @@ import com.lostf1sh.pixelplayeross.data.database.JellyfinPlaylistEntity import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.jellyfin.JellyfinRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -15,6 +16,71 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +/** Typed status of the sync banner so the UI can color it without parsing English text (F113). */ +enum class JellyfinSyncStatus { InProgress, Success, Partial, Error } + +/** Coarse, user-safe failure category so the banner never surfaces raw backend/exception text. */ +enum class JellyfinSyncErrorReason { Network, Auth, ServerUnavailable, Unknown } + +/** + * Sync banner state. Carries structured data so the UI renders localized strings via + * stringResource instead of pre-formatted English text (F112). + */ +sealed interface JellyfinSyncBanner { + val status: JellyfinSyncStatus + + data object SyncingAll : JellyfinSyncBanner { + override val status = JellyfinSyncStatus.InProgress + } + + data object SyncingPlaylists : JellyfinSyncBanner { + override val status = JellyfinSyncStatus.InProgress + } + + data object SyncingSongs : JellyfinSyncBanner { + override val status = JellyfinSyncStatus.InProgress + } + + data class SyncedSummary(val playlistCount: Int, val songCount: Int, val failedCount: Int) : JellyfinSyncBanner { + override val status: JellyfinSyncStatus + get() = if (failedCount == 0) JellyfinSyncStatus.Success else JellyfinSyncStatus.Partial + } + + data class SyncedPlaylists(val count: Int) : JellyfinSyncBanner { + override val status = JellyfinSyncStatus.Success + } + + data class SyncedSongs(val count: Int) : JellyfinSyncBanner { + override val status = JellyfinSyncStatus.Success + } + + data class Failed(val reason: JellyfinSyncErrorReason) : JellyfinSyncBanner { + override val status = JellyfinSyncStatus.Error + } +} + +/** + * Maps a sync failure to a coarse, user-safe category and logs the original throwable, so the + * banner shows a localized reason instead of leaking raw backend/OkHttp/server text (F112/F113). + */ +private fun Throwable.toJellyfinSyncErrorReason(): JellyfinSyncErrorReason = when (this) { + is java.net.UnknownHostException, + is java.net.ConnectException, + is java.net.SocketTimeoutException -> JellyfinSyncErrorReason.ServerUnavailable + is retrofit2.HttpException -> when (code()) { + 401, 403 -> JellyfinSyncErrorReason.Auth + in 500..599 -> JellyfinSyncErrorReason.ServerUnavailable + else -> JellyfinSyncErrorReason.Network + } + is java.io.IOException -> JellyfinSyncErrorReason.Network + else -> JellyfinSyncErrorReason.Unknown +} + +private fun jellyfinFailedBanner(throwable: Throwable): JellyfinSyncBanner.Failed { + Timber.e(throwable, "Jellyfin sync failed") + return JellyfinSyncBanner.Failed(throwable.toJellyfinSyncErrorReason()) +} + @HiltViewModel class JellyfinDashboardViewModel @Inject constructor( private val repository: JellyfinRepository @@ -26,34 +92,39 @@ class JellyfinDashboardViewModel @Inject constructor( private val _isSyncing = MutableStateFlow(false) val isSyncing: StateFlow = _isSyncing.asStateFlow() - private val _syncMessage = MutableStateFlow(null) - val syncMessage: StateFlow = _syncMessage.asStateFlow() + private val _syncBanner = MutableStateFlow(null) + val syncBanner: StateFlow = _syncBanner.asStateFlow() private val _selectedPlaylistSongs = MutableStateFlow>(emptyList()) val selectedPlaylistSongs: StateFlow> = _selectedPlaylistSongs.asStateFlow() + private var loadPlaylistSongsJob: Job? = null + val username: String? get() = repository.username val serverUrl: String? get() = repository.serverUrl val isLoggedIn: StateFlow = repository.isLoggedInFlow init { - syncAllPlaylistsAndSongs() + // Only auto-sync when the cached library is stale, instead of on every dashboard/home open (F111). + if (repository.isLibrarySyncStale()) { + syncAllPlaylistsAndSongs() + } } fun syncAllPlaylistsAndSongs() { viewModelScope.launch { _isSyncing.value = true - _syncMessage.value = "Syncing all playlists and songs..." + _syncBanner.value = JellyfinSyncBanner.SyncingAll val result = repository.syncAllPlaylistsAndSongs() result.fold( onSuccess = { summary -> - _syncMessage.value = if (summary.failedPlaylistCount == 0) { - "Synced ${summary.playlistCount} playlists, ${summary.syncedSongCount} songs" - } else { - "Synced ${summary.playlistCount} playlists, ${summary.syncedSongCount} songs (${summary.failedPlaylistCount} failed)" - } + _syncBanner.value = JellyfinSyncBanner.SyncedSummary( + playlistCount = summary.playlistCount, + songCount = summary.syncedSongCount, + failedCount = summary.failedPlaylistCount + ) }, - onFailure = { _syncMessage.value = "Sync failed: ${it.message}" } + onFailure = { _syncBanner.value = jellyfinFailedBanner(it) } ) _isSyncing.value = false } @@ -62,11 +133,11 @@ class JellyfinDashboardViewModel @Inject constructor( fun syncPlaylists() { viewModelScope.launch { _isSyncing.value = true - _syncMessage.value = "Syncing playlists..." + _syncBanner.value = JellyfinSyncBanner.SyncingPlaylists val result = repository.syncPlaylists() result.fold( - onSuccess = { _syncMessage.value = "Synced ${it.size} playlists" }, - onFailure = { _syncMessage.value = "Sync failed: ${it.message}" } + onSuccess = { _syncBanner.value = JellyfinSyncBanner.SyncedPlaylists(it.size) }, + onFailure = { _syncBanner.value = jellyfinFailedBanner(it) } ) _isSyncing.value = false } @@ -75,7 +146,7 @@ class JellyfinDashboardViewModel @Inject constructor( fun syncPlaylistSongs(playlistId: String) { viewModelScope.launch { _isSyncing.value = true - _syncMessage.value = "Syncing songs..." + _syncBanner.value = JellyfinSyncBanner.SyncingSongs val result = repository.syncPlaylistSongs(playlistId) result.fold( onSuccess = { count -> @@ -84,16 +155,17 @@ class JellyfinDashboardViewModel @Inject constructor( } catch (e: Exception) { Timber.e(e, "Failed to sync unified library after playlist sync") } - _syncMessage.value = "Synced $count songs" + _syncBanner.value = JellyfinSyncBanner.SyncedSongs(count) }, - onFailure = { _syncMessage.value = "Sync failed: ${it.message}" } + onFailure = { _syncBanner.value = jellyfinFailedBanner(it) } ) _isSyncing.value = false } } fun loadPlaylistSongs(playlistId: String) { - viewModelScope.launch { + loadPlaylistSongsJob?.cancel() + loadPlaylistSongsJob = viewModelScope.launch { repository.getPlaylistSongs(playlistId).collect { songs -> _selectedPlaylistSongs.value = songs } @@ -107,7 +179,7 @@ class JellyfinDashboardViewModel @Inject constructor( } fun clearSyncMessage() { - _syncMessage.value = null + _syncBanner.value = null } fun logout() { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/library/LibraryTabId.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/library/LibraryTabId.kt deleted file mode 100644 index da66009..0000000 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/library/LibraryTabId.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.lostf1sh.pixelplayeross.presentation.library - -import com.lostf1sh.pixelplayeross.data.model.SortOption -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json - -/** - * Stable identifiers for each library tab. The [stableKey] value is persisted so it must not - * change between app versions. - */ -enum class LibraryTabId( - val stableKey: String, - val label: String, - val sortOptions: List -) { - Songs( - stableKey = "SONGS", - label = "SONGS", - sortOptions = listOf( - SortOption.SongTitleAZ, - SortOption.SongTitleZA, - SortOption.SongArtist, - SortOption.SongArtistDesc, - SortOption.SongAlbum, - SortOption.SongAlbumDesc, - SortOption.SongDateAdded, - SortOption.SongDateAddedAsc, - SortOption.SongDuration, - SortOption.SongDurationAsc - ) - ), - Albums( - stableKey = "ALBUMS", - label = "ALBUMS", - sortOptions = listOf( - SortOption.AlbumTitleAZ, - SortOption.AlbumTitleZA, - SortOption.AlbumArtist, - SortOption.AlbumArtistDesc, - SortOption.AlbumReleaseYear, - SortOption.AlbumReleaseYearAsc, - SortOption.AlbumDateAdded - ) - ), - Artists( - stableKey = "ARTIST", - label = "ARTIST", - sortOptions = listOf( - SortOption.ArtistNameAZ, - SortOption.ArtistNameZA, - SortOption.ArtistNumSongsDesc, - SortOption.ArtistNumSongsAsc - ) - ), - Playlists( - stableKey = "PLAYLISTS", - label = "PLAYLISTS", - sortOptions = listOf( - SortOption.PlaylistNameAZ, - SortOption.PlaylistNameZA, - SortOption.PlaylistDateCreated, - SortOption.PlaylistDateCreatedAsc - ) - ), - Folders( - stableKey = "FOLDERS", - label = "FOLDERS", - sortOptions = listOf( - SortOption.FolderNameAZ, - SortOption.FolderNameZA, - SortOption.FolderSongCountAsc, - SortOption.FolderSongCountDesc, - SortOption.FolderSubdirCountAsc, - SortOption.FolderSubdirCountDesc - ) - ), - Liked( - stableKey = "LIKED", - label = "LIKED", - sortOptions = listOf( - SortOption.LikedSongTitleAZ, - SortOption.LikedSongTitleZA, - SortOption.LikedSongArtist, - SortOption.LikedSongArtistDesc, - SortOption.LikedSongAlbum, - SortOption.LikedSongAlbumDesc, - SortOption.LikedSongDateLiked, - SortOption.LikedSongDateLikedAsc - ) - ); - - companion object { - val defaultOrder: List = entries.toList() - - fun fromStableKey(key: String): LibraryTabId? = entries.firstOrNull { it.stableKey == key } - } -} - -internal fun decodeLibraryTabOrder(orderJson: String?): List { - val storedKeys = orderJson?.let { - runCatching { Json.decodeFromString>(it) }.getOrNull() - } ?: emptyList() - - val ordered = LinkedHashSet() - storedKeys.mapNotNull { LibraryTabId.fromStableKey(it) }.forEach { ordered.add(it) } - LibraryTabId.defaultOrder.forEach { ordered.add(it) } - return ordered.toList() -} \ No newline at end of file diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navidrome/dashboard/NavidromeDashboardScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navidrome/dashboard/NavidromeDashboardScreen.kt index 661b480..7cccbd5 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navidrome/dashboard/NavidromeDashboardScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navidrome/dashboard/NavidromeDashboardScreen.kt @@ -56,6 +56,7 @@ fun NavidromeDashboardScreen( val syncMessage by viewModel.syncMessage.collectAsStateWithLifecycle() val selectedPlaylistSongs by viewModel.selectedPlaylistSongs.collectAsStateWithLifecycle() val selectedPlaylistName by viewModel.selectedPlaylistName.collectAsStateWithLifecycle() + val credentialsUnencrypted by viewModel.credentialsUnencrypted.collectAsStateWithLifecycle() val cardShape = AbsoluteSmoothCornerShape( cornerRadiusTR = 20.dp, cornerRadiusTL = 20.dp, @@ -102,6 +103,7 @@ fun NavidromeDashboardScreen( syncMessage = syncMessage, selectedPlaylistSongs = selectedPlaylistSongs, selectedPlaylistName = selectedPlaylistName, + credentialsUnencrypted = credentialsUnencrypted, username = viewModel.username, lastSyncTime = viewModel.lastSyncTime, onSyncAll = { viewModel.syncAllPlaylistsAndSongs() }, @@ -126,6 +128,7 @@ private fun DashboardContent( syncMessage: String?, selectedPlaylistSongs: List, selectedPlaylistName: String?, + credentialsUnencrypted: Boolean, username: String?, lastSyncTime: Long, onSyncAll: () -> Unit, @@ -141,6 +144,27 @@ private fun DashboardContent( .fillMaxSize() .padding(paddingValues) ) { + // Unencrypted-credentials security warning (shown only when secure storage was unavailable) + if (credentialsUnencrypted) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = cardShape, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = stringResource(R.string.navidrome_credentials_unencrypted_warning), + style = MaterialTheme.typography.bodyMedium, + fontFamily = RoundedSans, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(16.dp) + ) + } + } + // Sync status banner AnimatedVisibility( visible = syncMessage != null, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navidrome/dashboard/NavidromeDashboardViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navidrome/dashboard/NavidromeDashboardViewModel.kt index 6a4d499..a66f42b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navidrome/dashboard/NavidromeDashboardViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navidrome/dashboard/NavidromeDashboardViewModel.kt @@ -50,6 +50,9 @@ class NavidromeDashboardViewModel @Inject constructor( val isLoggedIn: StateFlow = repository.isLoggedInFlow val lastSyncTime: Long get() = repository.lastFullSyncTime + /** True when secure storage was unavailable and credentials are stored unencrypted. */ + val credentialsUnencrypted: StateFlow = repository.credentialsUnencryptedFlow + init { observeSyncWorker() // Auto sync full library (songs + playlists) if it's been more than 24 hours diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navigation/AppNavigation.kt index a2d0442..9feafc5 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navigation/AppNavigation.kt @@ -1,6 +1,6 @@ package com.lostf1sh.pixelplayeross.presentation.navigation -import DelimiterConfigScreen +import com.lostf1sh.pixelplayeross.presentation.screens.DelimiterConfigScreen import com.lostf1sh.pixelplayeross.presentation.screens.WordDelimiterConfigScreen import android.annotation.SuppressLint import androidx.annotation.OptIn diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navigation/Screen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navigation/Screen.kt index 61d62b2..5034f0c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/navigation/Screen.kt @@ -1,6 +1,7 @@ package com.lostf1sh.pixelplayeross.presentation.navigation import androidx.compose.runtime.Immutable +import java.net.URLEncoder @Immutable @@ -25,7 +26,7 @@ sealed class Screen(val route: String) { object Stats : Screen("stats") object Duplicates : Screen("duplicates") object GenreDetail : Screen("genre_detail/{genreId}") { // New screen - fun createRoute(genreId: String) = "genre_detail/$genreId" + fun createRoute(genreId: String) = "genre_detail/" + URLEncoder.encode(genreId, "UTF-8") } object DJSpace : Screen("dj_space") // The base route is "album_detail". The full route with the argument is defined in AppNavigation. 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 bdb07d6..175ca3d 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 @@ -466,7 +466,7 @@ fun AlbumDetailScreen( onNavigateToGenre = { currentSong.genre?.let { navController.navigateSafelyReplacing( - route = Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8")), + route = Screen.GenreDetail.createRoute(it), patternToPop = Screen.GenreDetail.route ) } 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 851c823..8fb7a43 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 @@ -520,7 +520,7 @@ fun ArtistDetailScreen( onNavigateToGenre = { currentSong.genre?.let { navController.navigateSafelyReplacing( - route = Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8")), + route = Screen.GenreDetail.createRoute(it), patternToPop = Screen.GenreDetail.route ) } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/CreatePlaylistScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/CreatePlaylistScreen.kt index ace4f27..ef7e945 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/CreatePlaylistScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/CreatePlaylistScreen.kt @@ -116,7 +116,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import coil.ImageLoader +import coil.imageLoader import coil.compose.AsyncImage import coil.request.ImageRequest import com.lostf1sh.pixelplayeross.data.model.Song @@ -335,7 +335,7 @@ private fun CreatePlaylistContent( LaunchedEffect(selectedImageUri) { if (selectedImageUri != null) { - val loader = ImageLoader(context) + val loader = context.imageLoader val request = ImageRequest.Builder(context) .data(selectedImageUri) .allowHardware(false) @@ -780,7 +780,7 @@ fun EditPlaylistContent( // Image Loader LaunchedEffect(selectedImageUri) { if (selectedImageUri != null) { - val loader = ImageLoader(context) + val loader = context.imageLoader val request = ImageRequest.Builder(context) .data(selectedImageUri) .allowHardware(false) 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 7baba2d..ec7ec79 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 @@ -176,7 +176,7 @@ fun DailyMixScreen( }, onNavigateToGenre = { song.genre?.let { - navController.navigateSafely(Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8"))) + navController.navigateSafely(Screen.GenreDetail.createRoute(it)) } showSongInfoSheet = false }, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/DelimiterConfigScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/DelimiterConfigScreen.kt index f1453f3..65c0eee 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/DelimiterConfigScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/DelimiterConfigScreen.kt @@ -1,3 +1,5 @@ +package com.lostf1sh.pixelplayeross.presentation.screens + import android.widget.Toast import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/EditTransitionScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/EditTransitionScreen.kt index 9b659c2..6dfb32a 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/EditTransitionScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/EditTransitionScreen.kt @@ -108,9 +108,9 @@ fun EditTransitionScreen( // Configuration for the collapsible TopBar behavior (Material 3) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - LaunchedEffect(uiState.isSaved, isPlaylistScope, uiState.useGlobalDefaults) { - if (uiState.isSaved) { - val messageRes = if (isPlaylistScope && uiState.useGlobalDefaults) { + LaunchedEffect(Unit) { + viewModel.savedEvents.collect { usingGlobal -> + val messageRes = if (usingGlobal) { R.string.presentation_batch_d_transition_snackbar_using_global } else { R.string.presentation_batch_d_transition_snackbar_saved 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 52d4c0a..8d78907 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 @@ -522,7 +522,7 @@ fun GenreDetailScreen( onNavigateToGenre = { song.genre?.let { navController.navigateSafelyReplacing( - route = com.lostf1sh.pixelplayeross.presentation.navigation.Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8")), + route = com.lostf1sh.pixelplayeross.presentation.navigation.Screen.GenreDetail.createRoute(it), patternToPop = com.lostf1sh.pixelplayeross.presentation.navigation.Screen.GenreDetail.route ) } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/HomeScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/HomeScreen.kt index 05a37ab..28b8d30 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/HomeScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/HomeScreen.kt @@ -441,7 +441,7 @@ fun HomeScreen( }, onNavigateToGenre = { song -> song.genre?.let { - navController.navigateSafely(Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8"))) + navController.navigateSafely(Screen.GenreDetail.createRoute(it)) } }, playerViewModel = playerViewModel 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 522e598..998e1dc 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 @@ -343,7 +343,9 @@ fun LibraryAlbumsTab( val album = albums[index] if (album != null) { val albumSpecificColorSchemeFlow = - playerViewModel.themeStateHolder.getAlbumColorSchemeFlow(album.albumArtUriString ?: "") + remember(album.albumArtUriString) { + playerViewModel.themeStateHolder.getAlbumColorSchemeFlow(album.albumArtUriString ?: "") + } val rememberedOnClick = remember(album.id, onAlbumClick) { { onAlbumClick(album.id) } } @@ -413,7 +415,9 @@ fun LibraryAlbumsTab( val album = albums[index] if (album != null) { val albumSpecificColorSchemeFlow = - playerViewModel.themeStateHolder.getAlbumColorSchemeFlow(album.albumArtUriString ?: "") + remember(album.albumArtUriString) { + playerViewModel.themeStateHolder.getAlbumColorSchemeFlow(album.albumArtUriString ?: "") + } val rememberedOnClick = remember(album.id, onAlbumClick) { { onAlbumClick(album.id) } } 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 9fde6e0..2f934d3 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 @@ -213,6 +213,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape +import timber.log.Timber import androidx.compose.material3.FilledIconButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -450,7 +451,7 @@ fun LibraryScreen( haptic.performHapticFeedback(HapticFeedbackType.LongPress) // Only toggle selection, don't show sheet immediately (similar to songs multi-selection) playlistMultiSelectionState.toggleSelection(playlist) - android.util.Log.d("PlaylistMultiSelect", "Toggled: ${playlist.name}, total selected: ${playlistMultiSelectionState.selectedPlaylists.value.size}") + Timber.tag("PlaylistMultiSelect").d("Toggled playlist, total selected: %d", playlistMultiSelectionState.selectedPlaylists.value.size) } } @@ -1520,7 +1521,7 @@ fun LibraryScreen( onNavigateToGenre = { currentSong.genre?.let { navController.navigateSafelyReplacing( - route = Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8")), + route = Screen.GenreDetail.createRoute(it), patternToPop = Screen.GenreDetail.route ) } @@ -2497,17 +2498,17 @@ fun LibraryFoldersTab( val isRoot = targetPath == FOLDER_NAVIGATION_ROOT_KEY val activeFolder = if (isRoot) null else currentFolder val showPlaylistCards = playlistMode && activeFolder == null - val itemsToShow = remember(activeFolder, folders, flattenedFolders, currentSortOption) { + val itemsToShow = remember(activeFolder, folders, flattenedFolders, currentSortOption, showPlaylistCards) { when { showPlaylistCards -> flattenedFolders activeFolder != null -> sortMusicFoldersByOption(activeFolder.subFolders, currentSortOption) else -> sortMusicFoldersByOption(folders, currentSortOption) - } - }.toImmutableList() + }.toImmutableList() + } val songsToShow = remember(activeFolder, currentSortOption) { - sortSongsForFolderView(activeFolder?.songs ?: emptyList(), currentSortOption) - }.toImmutableList() + sortSongsForFolderView(activeFolder?.songs ?: emptyList(), currentSortOption).toImmutableList() + } val currentSong = stablePlayerState.currentSong val currentSongId = currentSong?.id val currentSongIndexInSongs = remember(songsToShow, currentSongId) { 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 96b35e0..8b5f0fe 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 @@ -903,7 +903,7 @@ fun PlaylistDetailScreen( onNavigateToGenre = { currentSong.genre?.let { navController.navigateSafelyReplacing( - route = Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8")), + route = Screen.GenreDetail.createRoute(it), patternToPop = Screen.GenreDetail.route ) } 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 ac7c5ad..e07e187 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 @@ -314,7 +314,7 @@ fun RecentlyPlayedScreen( }, onNavigateToGenre = { song.genre?.let { - navController.navigateSafely(Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8"))) + navController.navigateSafely(Screen.GenreDetail.createRoute(it)) } showSongInfoBottomSheet = false }, 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 7fdf65b..9d984a7 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 @@ -55,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -364,8 +365,7 @@ fun SearchScreen( onGenreClick = { genre -> Timber.tag("SearchScreen") .d("Genre clicked: ${genre.name} (ID: ${genre.id})") - val encodedGenreId = java.net.URLEncoder.encode(genre.id, "UTF-8") - navController.navigateSafely(Screen.GenreDetail.createRoute(encodedGenreId)) + navController.navigateSafely(Screen.GenreDetail.createRoute(genre.id)) }, playerViewModel = playerViewModel, modifier = Modifier.padding(top = 12.dp) @@ -491,7 +491,7 @@ fun SearchScreen( }, onNavigateToGenre = { currentSong.genre?.let { - navController.navigateSafely(Screen.GenreDetail.createRoute(java.net.URLEncoder.encode(it, "UTF-8"))) + navController.navigateSafely(Screen.GenreDetail.createRoute(it)) } showSongInfoBottomSheet = false }, @@ -858,9 +858,15 @@ fun SearchResultsList( } is SearchResultItem.PlaylistItem -> { - val playlistSongs by remember(item.playlist.songIds, playerViewModel) { - playerViewModel.observeSongs(item.playlist.songIds) - }.collectAsStateWithLifecycle(initialValue = emptyList()) + // One-shot snapshot for the cover collage instead of a live + // per-row Room Flow subscription (only thumbnails are needed). + val playlistSongs by produceState( + initialValue = emptyList(), + item.playlist.songIds, + playerViewModel + ) { + value = playerViewModel.getSongs(item.playlist.songIds) + } val coroutineScope = rememberCoroutineScope() val onPlayClick: () -> Unit = { coroutineScope.launch { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsScreen.kt index f228445..06290ae 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsScreen.kt @@ -277,7 +277,7 @@ fun SettingsScreen( ExpressiveCategoryItem( category = SettingsCategory.ABOUT, customColors = getCategoryColors(SettingsCategory.ABOUT, isDark), - onClick = { navController.navigateSafely("about") }, + onClick = { navController.navigateSafely(Screen.About.route) }, shape = shapeFor(itemIndex) ) } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ConnectivityStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ConnectivityStateHolder.kt index 6bacc3d..14e6b01 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ConnectivityStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ConnectivityStateHolder.kt @@ -575,9 +575,13 @@ class ConnectivityStateHolder @Inject constructor( wifiStateReceiver?.let { runCatching { context.unregisterReceiver(it) } } - bluetoothStateReceiver?.let { + bluetoothStateReceiver?.let { runCatching { context.unregisterReceiver(it) } } + audioDeviceCallback?.let { + runCatching { audioManager.unregisterAudioDeviceCallback(it) } + } + audioDeviceCallback = null if (hasBluetoothScanPermission()) { bluetoothAdapter?.takeIf { isBluetoothDiscoveryActive(it) }?.let { cancelBluetoothDiscovery(it) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/DailyMixStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/DailyMixStateHolder.kt index ea92280..4481cf0 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/DailyMixStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/DailyMixStateHolder.kt @@ -175,7 +175,10 @@ class DailyMixStateHolder @Inject constructor( return dailyMixManager.generateDailyMix(allSongs, favoriteIds, maxSize) } - fun onCleared() { + fun onCleared(owningScope: CoroutineScope) { + // Identity-safe release: only tear down if the scope belongs to the + // PlayerViewModel being cleared, so a sibling VM can't strand this holder (F15). + if (this.scope !== owningScope) return updateJob?.cancel() scope = null } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/EqualizerViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/EqualizerViewModel.kt index 6eab2b2..a00cd78 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/EqualizerViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/EqualizerViewModel.kt @@ -18,8 +18,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.Job import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber @@ -69,6 +67,7 @@ class EqualizerViewModel @Inject constructor( private val equalizerManager: EqualizerManager, private val equalizerPreferencesRepository: EqualizerPreferencesRepository, private val dualPlayerEngine: DualPlayerEngine, + @param:com.lostf1sh.pixelplayeross.di.AppScope private val appScope: CoroutineScope, @param:dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context ) : ViewModel() { @@ -491,7 +490,10 @@ class EqualizerViewModel @Inject constructor( private fun persistLatestStateAsync() { val latest = _uiState.value - CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + // Flush on the injected application-lifetime scope rather than a detached, un-cancellable + // ad-hoc scope. viewModelScope is already cancelled by onCleared, so we need a scope that + // outlives the ViewModel; appScope is a managed @Singleton (SupervisorJob + Dispatchers.IO). + appScope.launch { runCatching { equalizerPreferencesRepository.setEqualizerEnabled(latest.isEnabled) equalizerPreferencesRepository.setEqualizerPreset(latest.currentPreset.name) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/GenreDetailViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/GenreDetailViewModel.kt index 77ed6e4..eca7239 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/GenreDetailViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/GenreDetailViewModel.kt @@ -132,18 +132,18 @@ class GenreDetailViewModel @Inject constructor( try { // Step 1: Fast load of the Genre object to stabilize the UI theme as early as possible. // This prevents a major recomposition (theme switch) mid-animation. - val initialGenre = withContext(Dispatchers.Default) { - val genres = musicRepository.getGenres().first() - genres.find { it.id.equals(genreId, ignoreCase = true) } + // Collect getGenres() once and reuse the list in Step 2 to avoid re-running the aggregation. + val genres = withContext(Dispatchers.Default) { + musicRepository.getGenres().first() } - + val initialGenre = genres.find { it.id.equals(genreId, ignoreCase = true) } + if (initialGenre != null) { _uiState.value = _uiState.value.copy(genre = initialGenre, isLoadingGenreName = false) } // Step 2: Heavy data processing for songs and sections val result = withContext(Dispatchers.Default) { - val genres = musicRepository.getGenres().first() val genre = initialGenre ?: genres.find { it.id.equals(genreId, ignoreCase = true) } ?: Genre( id = genreId, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/LibraryStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/LibraryStateHolder.kt index ab00343..df0ff54 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/LibraryStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/LibraryStateHolder.kt @@ -217,7 +217,10 @@ class LibraryStateHolder @Inject constructor( } } - fun onCleared() { + fun onCleared(owningScope: CoroutineScope) { + // Identity-safe release: only null the scope if it belongs to the + // PlayerViewModel being cleared, so a sibling VM can't strand this holder (F15). + if (this.scope !== owningScope) return scope = null } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTracker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTracker.kt index 85d1ebe..8ba105c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTracker.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTracker.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -262,18 +263,33 @@ class ListeningStatsTracker @Inject constructor( } @Synchronized - fun onCleared() { + fun onCleared(owningScope: CoroutineScope) { + // Identity-safe release: only finalize and null the scope if it belongs to + // the PlayerViewModel being cleared, so a sibling VM can't strand this holder (F15). + if (this.scope !== owningScope) return finalizeCurrentSession(forceSynchronousPersistence = true) scope = null } - @Suppress("UNUSED_PARAMETER") private fun persistPlayback( songId: String, listened: Long, timestamp: Long, forceSynchronous: Boolean ) { + if (forceSynchronous) { + // Teardown path (onCleared): block until the final session is durably written so + // it is not lost if the process is killed immediately after. Stats are a protected + // data domain, and a detached coroutine on persistenceScope may not finish in time. + runCatching { + runBlocking(Dispatchers.IO) { + persistPlaybackInternal(songId = songId, listened = listened, timestamp = timestamp) + } + }.onFailure { throwable -> + Timber.e(throwable, "Failed to persist final listening session for song=%s", songId) + } + return + } persistenceScope.launch { runCatching { persistPlaybackInternal(songId = songId, listened = listened, timestamp = timestamp) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/LyricsStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/LyricsStateHolder.kt index 6729356..a3b5602 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/LyricsStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/LyricsStateHolder.kt @@ -413,7 +413,10 @@ class LyricsStateHolder @Inject constructor( } } - fun onCleared() { + fun onCleared(owningScope: CoroutineScope) { + // Identity-safe release: only tear down if the scope belongs to the + // PlayerViewModel being cleared, so a sibling VM can't strand this holder (F15). + if (this.scope !== owningScope) return loadingJob?.cancel() scope = null loadCallback = null diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MashupViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MashupViewModel.kt index 4c23160..da6d624 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MashupViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/MashupViewModel.kt @@ -48,11 +48,10 @@ class MashupViewModel @Inject constructor( private lateinit var deck2Controller: DeckController private var progressJob: Job? = null + private var loadSongsForPickerJob: Job? = null init { initializeDecks() - loadAllSongs() - startProgressUpdater() } private fun initializeDecks() { @@ -60,14 +59,6 @@ class MashupViewModel @Inject constructor( deck2Controller = DeckController(application) } - private fun loadAllSongs() { - viewModelScope.launch { - musicRepository.getAudioFiles().collect { songs -> - _uiState.update { it.copy(allSongs = songs) } - } - } - } - fun loadSong(deck: Int, song: Song) { updateDeckState(deck) { it.copy(song = song) } val songUri = Uri.parse(song.contentUriString) @@ -76,6 +67,7 @@ class MashupViewModel @Inject constructor( controller.player?.addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { updateDeckState(deck) { it.copy(isPlaying = isPlaying) } + syncProgressUpdater() } }) closeSongPicker() @@ -87,8 +79,14 @@ class MashupViewModel @Inject constructor( } fun playPause(deck: Int) { if (deck == 1) deck1Controller.playPause() else deck2Controller.playPause() } - fun seek(deck: Int, progress: Float) { if (deck == 1) deck1Controller.seek(progress) else deck2Controller.seek(progress) } - fun nudge(deck: Int, amountMs: Long) { if (deck == 1) deck1Controller.nudge(amountMs) else deck2Controller.nudge(amountMs) } + fun seek(deck: Int, progress: Float) { + if (deck == 1) deck1Controller.seek(progress) else deck2Controller.seek(progress) + pushDeckProgress() + } + fun nudge(deck: Int, amountMs: Long) { + if (deck == 1) deck1Controller.nudge(amountMs) else deck2Controller.nudge(amountMs) + pushDeckProgress() + } fun setVolume(deck: Int, volume: Float) { updateDeckState(deck) { it.copy(volume = volume.coerceIn(0f, 1f)) } @@ -115,24 +113,76 @@ class MashupViewModel @Inject constructor( deck2Controller.setDeckVolume(state.deck2.volume * vol2Multiplier) } + /** + * Starts the progress ticker when at least one deck is playing and stops it + * once neither deck is playing. Avoids the always-on 10Hz loop that drained + * CPU/battery while both decks were idle (mirrors the subscription/playback + * gating the rest of the app uses for its position ticker). + */ + private fun syncProgressUpdater() { + if (deck1Controller.player?.isPlaying == true || deck2Controller.player?.isPlaying == true) { + startProgressUpdater() + } else { + stopProgressUpdater() + } + } + private fun startProgressUpdater() { - progressJob?.cancel() + if (progressJob?.isActive == true) return progressJob = viewModelScope.launch { - while (isActive) { - updateDeckState(1) { it.copy(progress = deck1Controller.getProgress()) } - updateDeckState(2) { it.copy(progress = deck2Controller.getProgress()) } - delay(100) + while (isActive && + (deck1Controller.player?.isPlaying == true || deck2Controller.player?.isPlaying == true) + ) { + pushDeckProgress() + delay(PROGRESS_TICK_MS) + } + progressJob = null + } + } + + private fun stopProgressUpdater() { + progressJob?.cancel() + progressJob = null + // Emit a final snapshot so the slider lands on the paused/finished position. + pushDeckProgress() + } + + private fun pushDeckProgress() { + val progress1 = deck1Controller.getProgress() + val progress2 = deck2Controller.getProgress() + updateDeckState(1) { if (it.progress == progress1) it else it.copy(progress = progress1) } + updateDeckState(2) { if (it.progress == progress2) it else it.copy(progress = progress2) } + } + + fun openSongPicker(deck: Int) { + _uiState.update { it.copy(showSongPickerForDeck = deck) } + loadSongsForPickerJob?.cancel() + loadSongsForPickerJob = viewModelScope.launch { + val songs = musicRepository.getAllSongsOnce() + // Only publish if the picker is still open (it may have been dismissed). + _uiState.update { + if (it.showSongPickerForDeck != null) it.copy(allSongs = songs) else it } } } - fun openSongPicker(deck: Int) { _uiState.update { it.copy(showSongPickerForDeck = deck) } } - fun closeSongPicker() { _uiState.update { it.copy(showSongPickerForDeck = null) } } + fun closeSongPicker() { + loadSongsForPickerJob?.cancel() + loadSongsForPickerJob = null + _uiState.update { it.copy(showSongPickerForDeck = null, allSongs = emptyList()) } + } override fun onCleared() { super.onCleared() deck1Controller.release() deck2Controller.release() progressJob?.cancel() + loadSongsForPickerJob?.cancel() + } + + private companion object { + // Aligns with the slider tick used elsewhere in the app (>=250ms keeps the + // slider smooth without the previous 100ms/10Hz battery drain). + private const val PROGRESS_TICK_MS = 250L } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlaybackStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlaybackStateHolder.kt index be81c97..e2574fe 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlaybackStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlaybackStateHolder.kt @@ -736,11 +736,18 @@ class PlaybackStateHolder @Inject constructor( val coroutineScope = scope ?: return shuffleToggleJob = coroutineScope.launch { + // Resolve the controller before flipping any UI state or arming the cooldown. + // If the controller has not connected yet (cold start / widget / media-button + // launch), bail out cleanly without setting isShuffleTransitionInProgress or + // lastShuffleToggleFinishedAtMs, so the UI stays consistent and a retry is not + // swallowed by SHUFFLE_TOGGLE_COOLDOWN_MS. + val player = mediaController + if (player == null || currentSongs.isEmpty()) { + shuffleToggleJob = null + return@launch + } _stablePlayerState.update { it.copy(isShuffleTransitionInProgress = true) } try { - val player = mediaController ?: return@launch - if (currentSongs.isEmpty()) return@launch - val isCurrentlyShuffled = _stablePlayerState.value.isShuffleEnabled if (!isCurrentlyShuffled) { @@ -905,7 +912,11 @@ class PlaybackStateHolder @Inject constructor( } } - fun onCleared() { + fun onCleared(owningScope: CoroutineScope) { + // Identity-safe release: only tear down if the scope being cleared is the + // one this holder is currently bound to. A sibling PlayerViewModel must not + // null a scope it does not own (see F15). + if (this.scope !== owningScope) return stopProgressUpdates() shuffleToggleJob?.cancel() shuffleToggleJob = null 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 3d97a9f..ba279f4 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 @@ -2225,7 +2225,7 @@ class PlayerViewModel @Inject constructor( awaitPlayerCollapse() } - _artistNavigationRequests.emit(artistId) + _artistNavigationRequests.emit(resolvedId) } } @@ -3470,9 +3470,10 @@ class PlayerViewModel @Inject constructor( val favIds = favoriteSongIds.value.toMutableSet() var likedCount = 0 songs.forEach { song -> - if (!favIds.contains(song.id)) { - setFavoriteStatusEverywhere(song.id, true) - favIds.add(song.id) + val favoriteSongId = resolveFavoriteSongId(song) ?: return@forEach + if (!favIds.contains(favoriteSongId)) { + setFavoriteStatusEverywhere(favoriteSongId, true) + favIds.add(favoriteSongId) likedCount++ } } @@ -3496,9 +3497,10 @@ class PlayerViewModel @Inject constructor( val favIds = favoriteSongIds.value.toMutableSet() var unlikedCount = 0 songs.forEach { song -> - if (favIds.contains(song.id)) { - setFavoriteStatusEverywhere(song.id, false) - favIds.remove(song.id) + val favoriteSongId = resolveFavoriteSongId(song) ?: return@forEach + if (favIds.contains(favoriteSongId)) { + setFavoriteStatusEverywhere(favoriteSongId, false) + favIds.remove(favoriteSongId) unlikedCount++ } } @@ -4070,14 +4072,14 @@ class PlayerViewModel @Inject constructor( mediaControllerFuture.cancel(true) super.onCleared() stopProgressUpdates() - playbackStateHolder.onCleared() - listeningStatsTracker.onCleared() - dailyMixStateHolder.onCleared() - lyricsStateHolder.onCleared() - themeStateHolder.onCleared() - searchStateHolder.onCleared() - libraryStateHolder.onCleared() - sleepTimerStateHolder.onCleared() + playbackStateHolder.onCleared(viewModelScope) + listeningStatsTracker.onCleared(viewModelScope) + dailyMixStateHolder.onCleared(viewModelScope) + lyricsStateHolder.onCleared(viewModelScope) + themeStateHolder.onCleared(viewModelScope) + searchStateHolder.onCleared(viewModelScope) + libraryStateHolder.onCleared(viewModelScope) + sleepTimerStateHolder.onCleared(viewModelScope) connectivityStateHolder.onCleared() queueUndoStateHolder.onCleared() playlistDismissUndoStateHolder.onCleared() diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlaylistViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlaylistViewModel.kt index 06a7609..3a01fbb 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlaylistViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlaylistViewModel.kt @@ -1077,15 +1077,6 @@ class PlaylistViewModel @Inject constructor( viewModelScope.launch { try { Timber.tag("PlaylistViewModel").d("Starting export of ${playlistIds.size} playlists") - val musicDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_MUSIC) - if (!musicDir.exists()) { - musicDir.mkdirs() - } - - val exportDir = File(musicDir, "PixelPlayerOSS Exports") - if (!exportDir.exists()) { - exportDir.mkdirs() - } val playlistsWithSongs = getPlaylistsWithSongs(playlistIds) if (playlistsWithSongs.isEmpty()) { @@ -1094,20 +1085,53 @@ class PlaylistViewModel @Inject constructor( return@launch } - playlistsWithSongs.forEach { (playlist, songs) -> - val m3uContent = m3uManager.generateM3u(playlist, songs) - val baseName = sanitizeFileName(playlist.name) - var file = File(exportDir, "$baseName.m3u") - var counter = 1 - while (file.exists()) { - file = File(exportDir, "${baseName}_$counter.m3u") - counter++ + // Scoped-storage compliant write (minSdk 30): insert into the public Music + // directory via MediaStore instead of java.io.File, which a non-legacy app + // cannot use to create files in shared storage on Android 11+. + val relativePath = "${android.os.Environment.DIRECTORY_MUSIC}/PixelPlayerOSS Exports" + val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val resolver = context.contentResolver + + withContext(Dispatchers.IO) { + playlistsWithSongs.forEach { (playlist, songs) -> + val m3uContent = m3uManager.generateM3u(playlist, songs) + val baseName = sanitizeFileName(playlist.name) + val values = android.content.ContentValues().apply { + put(MediaStore.Audio.Media.DISPLAY_NAME, "$baseName.m3u") + put(MediaStore.Audio.Media.MIME_TYPE, "audio/x-mpegurl") + put(MediaStore.Audio.Media.RELATIVE_PATH, relativePath) + put(MediaStore.Audio.Media.IS_PENDING, 1) + } + + val itemUri = resolver.insert(collection, values) + ?: throw IOException("MediaStore insert returned null for ${playlist.name}") + try { + resolver.openOutputStream(itemUri)?.use { out -> + out.write(m3uContent.toByteArray()) + } ?: throw IOException("Could not open output stream for ${playlist.name}") + + val finalize = android.content.ContentValues().apply { + put(MediaStore.Audio.Media.IS_PENDING, 0) + } + val updated = resolver.update(itemUri, finalize, null, null) + if (updated != 1) { + // Item stays IS_PENDING (invisible to other apps) if finalize didn't + // apply; throw so the catch below removes the half-written entry. + throw IOException( + "Failed to finalize MediaStore item for ${playlist.name} " + + "(update count=$updated, uri=$itemUri)" + ) + } + Timber.tag("PlaylistViewModel").d("Exported playlist '${playlist.name}' to $itemUri") + } catch (e: Exception) { + // Remove the half-written pending entry so it does not linger. + runCatching { resolver.delete(itemUri, null, null) } + throw e + } } - file.writeText(m3uContent) - Timber.tag("PlaylistViewModel").d("Exported playlist '${playlist.name}' to ${file.absolutePath}") } - Timber.tag("PlaylistViewModel").d("Successfully exported ${playlistIds.size} playlists to $exportDir") + Timber.tag("PlaylistViewModel").d("Successfully exported ${playlistIds.size} playlists to $relativePath") val count = playlistsWithSongs.size val folderLabel = context.getString(R.string.playlist_export_folder_display) val exportedMsg = context.resources.getQuantityString(R.plurals.exported_playlists_message, count, count, folderLabel) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SearchStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SearchStateHolder.kt index 5f3f27d..3dde229 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SearchStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SearchStateHolder.kt @@ -198,7 +198,10 @@ class SearchStateHolder @Inject constructor( } } - fun onCleared() { + fun onCleared(owningScope: CoroutineScope) { + // Identity-safe release: only tear down if the scope belongs to the + // PlayerViewModel being cleared, so a sibling VM can't strand this holder (F15). + if (this.scope !== owningScope) return searchJob?.cancel() scope = null } 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 07f80fe..c76933e 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 @@ -979,7 +979,10 @@ class SettingsViewModel @Inject constructor( _dataTransferEvents.emit( context.getString(R.string.restore_partial_unresolved_format, failedNames), ) - if (result.succeeded.isNotEmpty() || !result.rolledBack) { + // Only sync when data was actually restored. Previously this also synced when + // !rolledBack was true, which conflated "something succeeded" with "rollback did + // not happen" and ran a full sync over a partially-written, non-rolled-back DB. + if (result.succeeded.isNotEmpty()) { syncManager.sync() } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SleepTimerStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SleepTimerStateHolder.kt index 27f4097..7d260b2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SleepTimerStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SleepTimerStateHolder.kt @@ -292,7 +292,10 @@ class SleepTimerStateHolder @Inject constructor( /** * Cleanup when ViewModel is cleared. */ - fun onCleared() { + fun onCleared(owningScope: CoroutineScope) { + // Identity-safe release: only tear down if the scope belongs to the + // PlayerViewModel being cleared, so a sibling VM can't strand this holder (F15). + if (this.scope !== owningScope) return sleepTimerJob?.cancel() eotSongMonitorJob?.cancel() scope = null diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SongRemovalStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SongRemovalStateHolder.kt index 894ae15..fff7a1b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SongRemovalStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SongRemovalStateHolder.kt @@ -61,8 +61,15 @@ class SongRemovalStateHolder @Inject constructor( } suspend fun removeSongFromLibrary(song: Song) { - libraryStateHolder.removeSong(song.id) - musicRepository.deleteById(song.id.toLong()) + // These two stores cannot share a transaction: playlists live in DataStore, + // the canonical song row lives in Room. Remove the playlist references first so + // that if the second op throws or the process dies in between, the worst residual + // state is an in-library song with no playlist entry (cosmetically harmless and + // self-correcting) rather than a playlist holding a dangling, deleted song id. playlistPreferencesRepository.removeSongFromAllPlaylists(song.id) + // Song.id is numeric only for local MediaStore songs; cloud (Navidrome/Jellyfin) ids are + // non-numeric strings, so guard the parse instead of force-casting it (which would throw). + song.id.toLongOrNull()?.let { musicRepository.deleteById(it) } + libraryStateHolder.removeSong(song.id) } } 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 bc3beeb..88983af 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 @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -82,7 +83,10 @@ class ThemeStateHolder @Inject constructor( colorAccuracyLevel = accuracy ) _currentAlbumArtColorSchemePair.value = refreshedScheme - individualAlbumColorSchemes[uri]?.value = refreshedScheme + val refreshTarget = synchronized(individualAlbumColorSchemesLock) { + individualAlbumColorSchemes[uri] + } + refreshTarget?.value = refreshedScheme } } @@ -133,7 +137,14 @@ class ThemeStateHolder @Inject constructor( } } - // LRU Cache for individual album schemes + // LRU Cache for individual album schemes. + // + // This map uses accessOrder=true, so even get() relinks an entry (a structural + // modification). It is touched from the Compose main thread (list composition), + // from IO coroutines (forceRegenerateColorScheme / palette refresh) and from + // Application.onTrimMemory, so every access MUST hold [individualAlbumColorSchemesLock] + // to avoid corrupting the linked list / ConcurrentModificationException. + private val individualAlbumColorSchemesLock = Any() private val individualAlbumColorSchemes = object : LinkedHashMap>( 32, 0.75f, true ) { @@ -198,29 +209,27 @@ class ThemeStateHolder @Inject constructor( ): StateFlow { if (uriString.isBlank()) return emptyAlbumColorScheme - val existingFlow = individualAlbumColorSchemes[uriString] - if (existingFlow != null) { - if (eager && existingFlow.value == null) { - requestAlbumColorSchemeGeneration(uriString, existingFlow) - } - return existingFlow.asStateFlow() + // Resolve (or create) the cached flow under the lock; only launch generation + // outside it so we never hold the monitor across a coroutine launch. + val targetFlow = synchronized(individualAlbumColorSchemesLock) { + individualAlbumColorSchemes[uriString] + ?: MutableStateFlow(null).also { individualAlbumColorSchemes[uriString] = it } } - val newFlow = MutableStateFlow(null) - individualAlbumColorSchemes[uriString] = newFlow - - if (eager) { - requestAlbumColorSchemeGeneration(uriString, newFlow) + if (eager && targetFlow.value == null) { + requestAlbumColorSchemeGeneration(uriString, targetFlow) } - return newFlow.asStateFlow() + return targetFlow.asStateFlow() } fun ensureAlbumColorScheme(uriString: String) { if (uriString.isBlank()) return - val targetFlow = individualAlbumColorSchemes[uriString] - ?: MutableStateFlow(null).also { individualAlbumColorSchemes[uriString] = it } + val targetFlow = synchronized(individualAlbumColorSchemesLock) { + individualAlbumColorSchemes[uriString] + ?: MutableStateFlow(null).also { individualAlbumColorSchemes[uriString] = it } + } if (targetFlow.value != null) return requestAlbumColorSchemeGeneration(uriString, targetFlow) @@ -244,8 +253,8 @@ class ThemeStateHolder @Inject constructor( return } - android.util.Log.d("ThemeStateHolder", "forceRegenerateColorScheme called for: $uriString") - android.util.Log.d("ThemeStateHolder", "Current tracked global URI: ${_currentAlbumArtUri.value}") + Timber.tag("ThemeStateHolder").d("forceRegenerateColorScheme called for: %s", uriString) + Timber.tag("ThemeStateHolder").d("Current tracked global URI: %s", _currentAlbumArtUri.value) colorSchemeProcessor.invalidateScheme(uriString) @@ -273,7 +282,9 @@ class ThemeStateHolder @Inject constructor( } // Iterate if there is an active flow for this URI and update it - val activeFlow = individualAlbumColorSchemes[uriString] + val activeFlow = synchronized(individualAlbumColorSchemesLock) { + individualAlbumColorSchemes[uriString] + } if (activeFlow != null) { activeFlow.value = newScheme } @@ -281,10 +292,10 @@ class ThemeStateHolder @Inject constructor( // Also update the main current album art scheme if it matches the one we are tracking // We use equality check. If they are the same string object or equal content. if (_currentAlbumArtUri.value == uriString) { - android.util.Log.d("ThemeStateHolder", "Updating global color scheme flow directly.") + Timber.tag("ThemeStateHolder").d("Updating global color scheme flow directly.") _currentAlbumArtColorSchemePair.value = newScheme } else { - android.util.Log.d("ThemeStateHolder", "Global URI did not match. Skipping global update.") + Timber.tag("ThemeStateHolder").d("Global URI did not match. Skipping global update.") } } @@ -298,7 +309,9 @@ class ThemeStateHolder @Inject constructor( level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND || level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN ) { - individualAlbumColorSchemes.clear() + synchronized(individualAlbumColorSchemesLock) { + individualAlbumColorSchemes.clear() + } } if ( @@ -311,7 +324,10 @@ class ThemeStateHolder @Inject constructor( } } - fun onCleared() { + fun onCleared(owningScope: CoroutineScope) { + // Identity-safe release: only null the scope if it belongs to the + // PlayerViewModel being cleared, so a sibling VM can't strand this holder (F15). + if (this.scope !== owningScope) return scope = null } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/TransitionViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/TransitionViewModel.kt index 8d3f879..4a54a73 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/TransitionViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/TransitionViewModel.kt @@ -9,9 +9,11 @@ import com.lostf1sh.pixelplayeross.data.model.TransitionRule import com.lostf1sh.pixelplayeross.data.model.TransitionSettings import com.lostf1sh.pixelplayeross.data.repository.TransitionRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -20,7 +22,6 @@ data class TransitionUiState( val rule: TransitionRule? = null, val globalSettings: TransitionSettings = TransitionSettings(), val isLoading: Boolean = true, - val isSaved: Boolean = false, val useGlobalDefaults: Boolean = false, val playlistId: String? = null ) @@ -36,6 +37,11 @@ class TransitionViewModel @Inject constructor( private val _uiState = MutableStateFlow(TransitionUiState()) val uiState = _uiState.asStateFlow() + // One-shot save confirmation event. The emitted Boolean carries the "using global" + // context so the screen can pick the snackbar message without keying on mutable UI state. + private val _savedEvents = Channel(Channel.CONFLATED) + val savedEvents = _savedEvents.receiveAsFlow() + init { loadSettings() } @@ -52,7 +58,6 @@ class TransitionViewModel @Inject constructor( globalSettings = globalSettings, isLoading = false, useGlobalDefaults = playlistRule == null, - isSaved = false, playlistId = playlistId ) } @@ -100,7 +105,6 @@ class TransitionViewModel @Inject constructor( _uiState.update { it.copy( rule = ruleToUpdate.copy(settings = newSettings), - isSaved = false, useGlobalDefaults = false ) } @@ -111,8 +115,7 @@ class TransitionViewModel @Inject constructor( _uiState.update { it.copy( rule = null, - useGlobalDefaults = true, - isSaved = false + useGlobalDefaults = true ) } } @@ -127,8 +130,7 @@ class TransitionViewModel @Inject constructor( _uiState.update { it.copy( rule = rule.copy(settings = baseSettings), - useGlobalDefaults = false, - isSaved = false + useGlobalDefaults = false ) } } @@ -136,6 +138,8 @@ class TransitionViewModel @Inject constructor( fun saveSettings() { viewModelScope.launch { val ruleToSave = _uiState.value.rule + // Capture the "using global" context before loadSettings() mutates the state. + val usingGlobal = playlistId != null && _uiState.value.useGlobalDefaults if (playlistId != null) { when { @@ -148,7 +152,7 @@ class TransitionViewModel @Inject constructor( transitionRepository.saveGlobalSettings(getCurrentSettings()) } loadSettings() - _uiState.update { it.copy(isSaved = true) } + _savedEvents.send(usingGlobal) } } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/PixelPlayerGlanceWidget.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/PixelPlayerGlanceWidget.kt index 2309cc7..c2cea5f 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/PixelPlayerGlanceWidget.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/PixelPlayerGlanceWidget.kt @@ -25,6 +25,7 @@ import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.background import androidx.glance.currentState import androidx.glance.layout.Alignment @@ -51,6 +52,8 @@ import androidx.glance.unit.ColorProvider import com.lostf1sh.pixelplayeross.data.model.QueueItem import com.lostf1sh.pixelplayeross.utils.createScalableBackgroundBitmap import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber class PixelPlayerGlanceWidget : GlanceAppWidget() { @@ -69,12 +72,24 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { private val EXTRA_LARGE_LAYOUT_SIZE = DpSize(width = 300.dp, height = 220.dp) private val EXTRA_LARGE_PLUS_LAYOUT_SIZE = DpSize(width = 350.dp, height = 260.dp) private val HUGE_LAYOUT_SIZE = DpSize(width = 400.dp, height = 300.dp) + + // Number of queue thumbnails the ExtraLarge layout actually renders; prewarm matches it. + private const val EXTRA_LARGE_QUEUE_THUMBNAIL_COUNT = 4 } override val sizeMode = SizeMode.Exact override val stateDefinition = PlayerInfoStateDefinition override suspend fun provideGlance(context: Context, id: GlanceId) { + // Pre-decode album art (URI-backed) off the composition path, before provideContent, + // so the @Composable body hits AlbumArtBitmapCache instead of performing blocking + // ContentResolver/file I/O synchronously during Glance composition. + runCatching { + getAppWidgetState(context, PlayerInfoStateDefinition, id) + }.getOrNull()?.let { state -> + prewarmArtworkCache(context, state) + } + provideContent { val playerInfo = currentState() val currentSize = LocalSize.current @@ -88,6 +103,51 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { } } + /** + * Decodes URI-backed album art into [AlbumArtBitmapCache] off the composition thread. + * The composable reads the same cache keys ("uri:"), so after this runs its + * lookups hit the cache and it never performs blocking ContentResolver/file I/O during + * Glance composition. Embedded [PlayerInfo.albumArtBitmapData] is left to the composable + * (in-memory decode, no I/O). Failures are swallowed so the widget still renders. + */ + private suspend fun prewarmArtworkCache(context: Context, playerInfo: PlayerInfo) { + withContext(Dispatchers.IO) { + val density = context.resources.displayMetrics.density + + // Primary art only when there is no embedded bitmap (the composable's URI fallback). + if (playerInfo.albumArtBitmapData == null) { + playerInfo.albumArtUri?.let { rawUri -> + val targetPx = (220f * density).toInt().coerceAtLeast(1) + decodeIntoCacheIfAbsent(context, rawUri, targetPx) + } + } + + // Queue thumbnails (ExtraLarge layout) decode at 58.dp; match that exactly. Only the + // first EXTRA_LARGE_QUEUE_THUMBNAIL_COUNT items are ever rendered, so don't prewarm more. + val queueTargetPx = (58f * density).toInt().coerceAtLeast(1) + playerInfo.queue.take(EXTRA_LARGE_QUEUE_THUMBNAIL_COUNT).forEach { item -> + item.albumArtUri?.let { rawUri -> + decodeIntoCacheIfAbsent(context, rawUri, queueTargetPx) + } + } + } + } + + private fun decodeIntoCacheIfAbsent(context: Context, rawUri: String, targetPx: Int) { + val cacheKey = "uri:$rawUri" + if (AlbumArtBitmapCache.getBitmap(cacheKey) != null) return + runCatching { + decodeWidgetAlbumArtBitmap( + context = context, + rawUri = rawUri, + targetWidthPx = targetPx, + targetHeightPx = targetPx, + ) + }.getOrNull()?.let { bitmap -> + AlbumArtBitmapCache.putBitmap(cacheKey, bitmap) + } + } + @Composable private fun WidgetUi( playerInfo: PlayerInfo, @@ -104,8 +164,13 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { Timber.tag("PixelPlayerGlanceWidget") .d("WidgetUi: PlayerInfo received. Title: $title, Artist: $artist, HasBitmapData: ${albumArtBitmapData != null}, BitmapDataSize: ${albumArtBitmapData?.size ?: "N/A"}") - val actualBackgroundColor = GlanceTheme.colors.surface - val onBackgroundColor = GlanceTheme.colors.onSurface + // Source colors from the album-art theme palette computed by MusicService + // (PlayerInfo.themeColors), falling back to GlanceTheme when it is null. This + // mirrors the sibling widgets (BarWidget4x1, ControlWidget4x2, GridWidget2x2) + // so the primary widget honors the same accent instead of discarding it. + val colors = playerInfo.getWidgetColors() + val actualBackgroundColor = colors.surface + val onBackgroundColor = colors.onSurface val baseModifier = GlanceModifier .fillMaxSize() @@ -123,7 +188,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { modifier = baseModifier, backgroundColor = actualBackgroundColor, bgCornerRadius = 60.dp, - isPlaying = isPlaying + isPlaying = isPlaying, + colors = colors ) size.height <= GABE_TWO_HEIGHT_LAYOUT_SIZE.height -> GabeTwoHeightWidgetLayout( modifier = baseModifier, @@ -132,7 +198,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtBitmapData = albumArtBitmapData, albumArtUri = albumArtUri, isPlaying = isPlaying, - context = context + context = context, + colors = colors ) else -> GabeWidgetLayout( modifier = baseModifier, @@ -141,7 +208,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtBitmapData = albumArtBitmapData, albumArtUri = albumArtUri, isPlaying = isPlaying, - context = context + context = context, + colors = colors ) } } else if (isSmallHeight) { @@ -153,7 +221,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtBitmapData = albumArtBitmapData, albumArtUri = albumArtUri, isPlaying = isPlaying, - context = context + context = context, + colors = colors ) size.width < THIN_LAYOUT_SIZE.width -> VeryThinWidgetLayout( modifier = baseModifier, @@ -165,7 +234,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { textColor = onBackgroundColor, context = context, backgroundColor = actualBackgroundColor, - bgCornerRadius = 60.dp + bgCornerRadius = 60.dp, + colors = colors ) else -> ThinWidgetLayout( modifier = baseModifier, @@ -177,7 +247,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtUri = albumArtUri, isPlaying = isPlaying, textColor = onBackgroundColor, - context = context + context = context, + colors = colors ) } } else { @@ -189,7 +260,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtBitmapData = albumArtBitmapData, albumArtUri = albumArtUri, isPlaying = isPlaying, - context = context + context = context, + colors = colors ) size.width < LARGE_LAYOUT_SIZE.width || size.height < LARGE_LAYOUT_SIZE.height -> MediumWidgetLayout( modifier = baseModifier, @@ -201,7 +273,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { textColor = onBackgroundColor, context = context, backgroundColor = actualBackgroundColor, - bgCornerRadius = 28.dp + bgCornerRadius = 28.dp, + colors = colors ) size.width < EXTRA_LARGE_LAYOUT_SIZE.width || size.height < EXTRA_LARGE_LAYOUT_SIZE.height -> LargeWidgetLayout( modifier = baseModifier, @@ -214,7 +287,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { isPlaying = isPlaying, isFavorite = isFavorite, textColor = onBackgroundColor, - context = context + context = context, + colors = colors ) else -> ExtraLargeWidgetLayout( modifier = baseModifier, @@ -227,7 +301,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { bgCornerRadius = 28.dp, textColor = onBackgroundColor, context = context, - queue = playerInfo.queue + queue = playerInfo.queue, + colors = colors ) } } @@ -245,12 +320,13 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtUri: String?, isPlaying: Boolean, textColor: ColorProvider, - context: Context + context: Context, + colors: WidgetColors ) { - val secondaryColor = GlanceTheme.colors.secondaryContainer - val onSecondaryColor = GlanceTheme.colors.onSecondaryContainer - val primaryContainerColor = GlanceTheme.colors.primaryContainer - val onPrimaryContainerColor = GlanceTheme.colors.onPrimaryContainer + val secondaryColor = colors.prevNextBackground + val onSecondaryColor = colors.prevNextIcon + val primaryContainerColor = colors.playPauseBackground + val onPrimaryContainerColor = colors.playPauseIcon val size = LocalSize.current val albumArtSize = size.height - 32.dp @@ -280,7 +356,7 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { Column(modifier = GlanceModifier.defaultWeight()) { Text(text = title, style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = textColor), maxLines = 1) if (artist.isNotEmpty() && artist != context.getString(R.string.widget_tap_to_open)) { - Text(text = artist, style = TextStyle(fontSize = 14.sp, color = textColor), maxLines = 1) + Text(text = artist, style = TextStyle(fontSize = 14.sp, color = colors.artist), maxLines = 1) } } Spacer(GlanceModifier.width(8.dp)) @@ -322,12 +398,13 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtUri: String?, isPlaying: Boolean, textColor: ColorProvider, - context: Context + context: Context, + colors: WidgetColors ) { - val secondaryColor = GlanceTheme.colors.secondaryContainer - val onSecondaryColor = GlanceTheme.colors.onSecondaryContainer - val primaryContainerColor = GlanceTheme.colors.primaryContainer - val onPrimaryContainerColor = GlanceTheme.colors.onPrimaryContainer + val secondaryColor = colors.prevNextBackground + val onSecondaryColor = colors.prevNextIcon + val primaryContainerColor = colors.playPauseBackground + val onPrimaryContainerColor = colors.playPauseIcon val size = LocalSize.current val albumArtSize = size.height - 32.dp @@ -358,7 +435,7 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { Column(modifier = GlanceModifier.defaultWeight()) { Text(text = title, style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = textColor), maxLines = 1) if (artist.isNotEmpty() && artist != context.getString(R.string.widget_tap_to_open)) { - Text(text = artist, style = TextStyle(fontSize = 14.sp, color = textColor), maxLines = 1) + Text(text = artist, style = TextStyle(fontSize = 14.sp, color = colors.artist), maxLines = 1) } } Spacer(GlanceModifier.width(8.dp)) @@ -396,12 +473,13 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtBitmapData: ByteArray?, albumArtUri: String?, isPlaying: Boolean, - context: Context + context: Context, + colors: WidgetColors ) { - val secondaryColor = GlanceTheme.colors.secondaryContainer - val onSecondaryColor = GlanceTheme.colors.onSecondaryContainer - val primaryContainerColor = GlanceTheme.colors.primaryContainer - val onPrimaryContainerColor = GlanceTheme.colors.onPrimaryContainer + val secondaryColor = colors.prevNextBackground + val onSecondaryColor = colors.prevNextIcon + val primaryContainerColor = colors.playPauseBackground + val onPrimaryContainerColor = colors.playPauseIcon Box( modifier = modifier @@ -466,12 +544,13 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtBitmapData: ByteArray?, albumArtUri: String?, isPlaying: Boolean, - context: Context + context: Context, + colors: WidgetColors ) { - val secondaryColor = GlanceTheme.colors.secondaryContainer - val onSecondaryColor = GlanceTheme.colors.onSecondaryContainer - val primaryContainerColor = GlanceTheme.colors.primaryContainer - val onPrimaryContainerColor = GlanceTheme.colors.onPrimaryContainer + val secondaryColor = colors.prevNextBackground + val onSecondaryColor = colors.prevNextIcon + val primaryContainerColor = colors.playPauseBackground + val onPrimaryContainerColor = colors.playPauseIcon Box( modifier = modifier @@ -540,10 +619,11 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { modifier: GlanceModifier, backgroundColor: ColorProvider, bgCornerRadius: Dp, - isPlaying: Boolean + isPlaying: Boolean, + colors: WidgetColors ) { - val primaryContainerColor = GlanceTheme.colors.primaryContainer - val onPrimaryContainerColor = GlanceTheme.colors.onPrimaryContainer + val primaryContainerColor = colors.playPauseBackground + val onPrimaryContainerColor = colors.playPauseIcon Box( modifier = modifier @@ -571,10 +651,11 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtBitmapData: ByteArray?, albumArtUri: String?, isPlaying: Boolean, - context: Context + context: Context, + colors: WidgetColors ) { - val primaryContainerColor = GlanceTheme.colors.primaryContainer - val onPrimaryContainerColor = GlanceTheme.colors.onPrimaryContainer + val primaryContainerColor = colors.playPauseBackground + val onPrimaryContainerColor = colors.playPauseIcon Box( modifier = modifier @@ -620,12 +701,13 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtBitmapData: ByteArray?, albumArtUri: String?, isPlaying: Boolean, - context: Context + context: Context, + colors: WidgetColors ) { - val secondaryColor = GlanceTheme.colors.secondaryContainer - val onSecondaryColor = GlanceTheme.colors.onSecondaryContainer - val primaryContainerColor = GlanceTheme.colors.primaryContainer - val onPrimaryContainerColor = GlanceTheme.colors.onPrimaryContainer + val secondaryColor = colors.prevNextBackground + val onSecondaryColor = colors.prevNextIcon + val primaryContainerColor = colors.playPauseBackground + val onPrimaryContainerColor = colors.playPauseIcon val buttonCornerRadius = 16.dp val playButtonCornerRadius = if (isPlaying) 12.dp else 60.dp @@ -711,12 +793,13 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { albumArtUri: String?, isPlaying: Boolean, textColor: ColorProvider, - context: Context + context: Context, + colors: WidgetColors ) { - val secondaryColor = GlanceTheme.colors.secondaryContainer - val onSecondaryColor = GlanceTheme.colors.onSecondaryContainer - val primaryContainerColor = GlanceTheme.colors.primaryContainer - val onPrimaryContainerColor = GlanceTheme.colors.onPrimaryContainer + val secondaryColor = colors.prevNextBackground + val onSecondaryColor = colors.prevNextIcon + val primaryContainerColor = colors.playPauseBackground + val onPrimaryContainerColor = colors.playPauseIcon val buttonCornerRadius = 60.dp val playButtonCornerRadius = if (isPlaying) 14.dp else 60.dp @@ -758,7 +841,7 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { Spacer(GlanceModifier.height(4.dp)) Text( text = artist, - style = TextStyle(fontSize = 13.sp, color = textColor), + style = TextStyle(fontSize = 13.sp, color = colors.artist), maxLines = 2 ) } @@ -818,7 +901,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { isPlaying: Boolean, isFavorite: Boolean, textColor: ColorProvider, - context: Context + context: Context, + colors: WidgetColors ) { // *** FIX: Apply padding to the outer Box for consistency *** Box( @@ -840,7 +924,7 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { Spacer(GlanceModifier.width(12.dp)) Column(modifier = GlanceModifier.defaultWeight()) { Text(text = title, style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = textColor), maxLines = 1) - Text(text = artist, style = TextStyle(fontSize = 13.sp, color = textColor), maxLines = 1) + Text(text = artist, style = TextStyle(fontSize = 13.sp, color = colors.artist), maxLines = 1) } Spacer(GlanceModifier.width(4.dp)) Image( @@ -865,10 +949,10 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { .fillMaxHeight(), verticalAlignment = Alignment.CenterVertically ) { - val secondaryColor = GlanceTheme.colors.secondaryContainer - val onSecondaryColor = GlanceTheme.colors.onSecondaryContainer - val primaryContainerColor = GlanceTheme.colors.primaryContainer - val onPrimaryContainerColor = GlanceTheme.colors.onPrimaryContainer + val secondaryColor = colors.prevNextBackground + val onSecondaryColor = colors.prevNextIcon + val primaryContainerColor = colors.playPauseBackground + val onPrimaryContainerColor = colors.playPauseIcon val buttonCornerRadius = 60.dp val playButtonCornerRadius = if (isPlaying) 14.dp else 60.dp @@ -911,7 +995,8 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { isPlaying: Boolean, backgroundColor: ColorProvider, bgCornerRadius: Dp, textColor: ColorProvider, context: Context, - queue: List + queue: List, + colors: WidgetColors ) { val playButtonCornerRadius = if (isPlaying) 16.dp else 60.dp @@ -945,7 +1030,7 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { ) Text( text = artist, - style = TextStyle(fontSize = 16.sp, color = textColor), + style = TextStyle(fontSize = 16.sp, color = colors.artist), maxLines = 1 ) } @@ -962,10 +1047,10 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { , verticalAlignment = Alignment.CenterVertically ) { - val secondaryColor = GlanceTheme.colors.secondaryContainer - val onSecondaryColor = GlanceTheme.colors.onSecondaryContainer - val primaryContainerColor = GlanceTheme.colors.primaryContainer - val onPrimaryContainerColor = GlanceTheme.colors.onPrimaryContainer + val secondaryColor = colors.prevNextBackground + val onSecondaryColor = colors.prevNextIcon + val primaryContainerColor = colors.playPauseBackground + val onPrimaryContainerColor = colors.playPauseIcon val buttonCornerRadius = 60.dp PreviousButtonGlance( @@ -1031,11 +1116,11 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { .height(58.dp), verticalAlignment = Alignment.CenterVertically ) { - val items = queue.take(4) + val items = queue.take(EXTRA_LARGE_QUEUE_THUMBNAIL_COUNT) val itemSize = 58.dp val cornerRadius = 14.dp - for (i in 0 until 4) { + for (i in 0 until EXTRA_LARGE_QUEUE_THUMBNAIL_COUNT) { Box( modifier = GlanceModifier.defaultWeight(), contentAlignment = Alignment.Center @@ -1092,7 +1177,21 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { val widgetDpSize = LocalSize.current // Get the actual size of the composable val imageProvider = bitmapData?.let { data -> - val cacheKey = AlbumArtBitmapCache.getKey(data) + // Compute the target size first so it can be folded into the cache key — otherwise the + // same bytes decoded for differently-sized widgets would collide on one entry and a + // smaller cached bitmap could be served to a larger widget. + val (targetWidthPx, targetHeightPx) = with(context.resources.displayMetrics) { + if (size != null) { + // Square logic when an explicit size is provided. + val targetSizePx = (size.value * density).toInt().coerceAtLeast(1) + targetSizePx to targetSizePx + } else { + // Otherwise use the actual widget size. + (widgetDpSize.width.value * density).toInt().coerceAtLeast(1) to + (widgetDpSize.height.value * density).toInt().coerceAtLeast(1) + } + } + val cacheKey = AlbumArtBitmapCache.getKey(data, targetWidthPx, targetHeightPx) var bitmap = AlbumArtBitmapCache.getBitmap(cacheKey) if (bitmap != null) { @@ -1109,24 +1208,6 @@ class PixelPlayerGlanceWidget : GlanceAppWidget() { val imageWidth = options.outWidth var inSampleSize = 1 - // Determine target size in pixels - val targetWidthPx: Int - val targetHeightPx: Int - with(context.resources.displayMetrics) { - if (size != null) { - // If size is provided, use it for both width and height (maintains square logic) - val targetSizePx = (size.value * density).toInt() - targetWidthPx = targetSizePx - targetHeightPx = targetSizePx - Timber.tag(TAG_AAIG).d("Target Px size for Dp $size: $targetSizePx") - } else { - // If size is not provided, use the actual widget size - targetWidthPx = (widgetDpSize.width.value * density).toInt() - targetHeightPx = (widgetDpSize.height.value * density).toInt() - Timber.tag(TAG_AAIG).d("Target Px size from widget DpSize ${widgetDpSize}: ${targetWidthPx}x${targetHeightPx}") - } - } - if (imageHeight > targetHeightPx || imageWidth > targetWidthPx) { val halfHeight: Int = imageHeight / 2 val halfWidth: Int = imageWidth / 2 diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/WidgetComponents.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/WidgetComponents.kt index defc6ba..51e1045 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/WidgetComponents.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/WidgetComponents.kt @@ -37,7 +37,10 @@ fun AlbumArtImage( cornerRadius: Dp ) { val imageProvider = bitmapData?.let { data -> - val cacheKey = AlbumArtBitmapCache.getKey(data) + // Fold the target size into the cache key so the same bytes decoded at different sizes + // don't collide on one entry (which could serve a smaller cached bitmap to a larger widget). + val targetSizePx = (size.value * context.resources.displayMetrics.density).toInt().coerceAtLeast(1) + val cacheKey = AlbumArtBitmapCache.getKey(data, targetSizePx, targetSizePx) var bitmap = AlbumArtBitmapCache.getBitmap(cacheKey) if (bitmap == null) { @@ -48,8 +51,6 @@ fun AlbumArtImage( BitmapFactory.decodeByteArray(data, 0, data.size, options) var inSampleSize = 1 - // Calculate target size in pixels - val targetSizePx = (size.value * context.resources.displayMetrics.density).toInt() if (options.outHeight > targetSizePx || options.outWidth > targetSizePx) { val halfHeight = options.outHeight / 2 @@ -73,7 +74,10 @@ fun AlbumArtImage( } bitmap?.let { ImageProvider(it) } } ?: albumArtUri?.let { rawUri -> - val cacheKey = "uri:$rawUri" + // Key by URI *and* requested size so the same artwork at different widget sizes doesn't + // collide (a smaller cached bitmap served to a larger slot), matching the size-keyed + // embedded path above. These widgets don't prewarm, so there's no shared key to honor. + val cacheKey = "uri:$rawUri@${size.value}" var bitmap = AlbumArtBitmapCache.getBitmap(cacheKey) if (bitmap == null) { bitmap = decodeAlbumArtFromUri( diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/WidgetUtils.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/WidgetUtils.kt index 1451882..7be96a4 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/WidgetUtils.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/ui/glancewidget/WidgetUtils.kt @@ -2,6 +2,7 @@ package com.lostf1sh.pixelplayeross.ui.glancewidget import android.graphics.Bitmap import android.util.LruCache +import java.security.MessageDigest import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.glance.GlanceTheme @@ -20,14 +21,33 @@ object AlbumArtBitmapCache { fun getBitmap(key: String): Bitmap? = lruCache.get(key) fun putBitmap(key: String, bitmap: Bitmap) { - if (getBitmap(key) == null) { - lruCache.put(key, bitmap) - } + // Always store the freshly decoded bitmap so a stale entry (e.g. a + // URI-keyed entry whose underlying artwork changed) is replaced rather + // than kept for the LRU lifetime. + lruCache.put(key, bitmap) } - fun getKey(byteArray: ByteArray): String { - return byteArray.contentHashCode().toString() + fun getKey(byteArray: ByteArray, width: Int, height: Int): String { + // Use a strong content digest (SHA-256) instead of the 32-bit + // contentHashCode() so two distinct album-art byte arrays do not collide + // onto the same cache entry and surface the wrong artwork. The render + // dimensions are appended so the same bytes decoded at different sizes get + // distinct entries — a smaller cached bitmap is never served to a larger slot. + val digest = MessageDigest.getInstance("SHA-256").digest(byteArray) + return buildString(digest.size * 2 + 12) { + for (b in digest) { + val v = b.toInt() and 0xFF + append(HEX_CHARS[v ushr 4]) + append(HEX_CHARS[v and 0x0F]) + } + append('@') + append(width) + append('x') + append(height) + } } + + private val HEX_CHARS = "0123456789abcdef".toCharArray() } data class WidgetColors( diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AudioDecoder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AudioDecoder.kt index cb60557..d006fa1 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AudioDecoder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AudioDecoder.kt @@ -17,69 +17,68 @@ object AudioDecoder { private const val ENCODING_PCM_FLOAT = 4 suspend fun decodeToFloatArray(context: Context, uri: Uri, requiredSamples: Int): Result = withContext(Dispatchers.IO) { + var extractor: MediaExtractor? = null + var decoder: MediaCodec? = null runCatching { - val extractor = MediaExtractor() - extractor.setDataSource(context, uri, null) + val activeExtractor = MediaExtractor().also { extractor = it } + activeExtractor.setDataSource(context, uri, null) - val trackIndex = findAudioTrack(extractor) + val trackIndex = findAudioTrack(activeExtractor) if (trackIndex == -1) { - extractor.release() error("No audio track found in the file.") } - extractor.selectTrack(trackIndex) - val format = extractor.getTrackFormat(trackIndex) + activeExtractor.selectTrack(trackIndex) + val format = activeExtractor.getTrackFormat(trackIndex) val mime = format.getString(MediaFormat.KEY_MIME) ?: error("MIME type not found.") - val decoder = MediaCodec.createDecoderByType(mime) - decoder.configure(format, null, null, 0) - decoder.start() + val activeDecoder = MediaCodec.createDecoderByType(mime).also { decoder = it } + activeDecoder.configure(format, null, null, 0) + activeDecoder.start() val pcmData = mutableListOf() val bufferInfo = MediaCodec.BufferInfo() var isEndOfStream = false while (!isEndOfStream && pcmData.size < requiredSamples) { // --- MODIFIED: stop condition --- - val inputBufferIndex = decoder.dequeueInputBuffer(TIMEOUT_US) + val inputBufferIndex = activeDecoder.dequeueInputBuffer(TIMEOUT_US) if (inputBufferIndex >= 0) { - val inputBuffer = decoder.getInputBuffer(inputBufferIndex) + val inputBuffer = activeDecoder.getInputBuffer(inputBufferIndex) if (inputBuffer == null) { Timber.tag("AudioDecoder").w("Decoder input buffer was null, ending decode early") - decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + activeDecoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) isEndOfStream = true continue } - val sampleSize = extractor.readSampleData(inputBuffer, 0) + val sampleSize = activeExtractor.readSampleData(inputBuffer, 0) if (sampleSize < 0) { - decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + activeDecoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) isEndOfStream = true } else { - decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, extractor.sampleTime, 0) - extractor.advance() + activeDecoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, activeExtractor.sampleTime, 0) + activeExtractor.advance() } } - var outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) + var outputBufferIndex = activeDecoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) while (outputBufferIndex >= 0) { - val outputBuffer = decoder.getOutputBuffer(outputBufferIndex) + val outputBuffer = activeDecoder.getOutputBuffer(outputBufferIndex) if (outputBuffer == null) { Timber.tag("AudioDecoder").w("Decoder output buffer was null, skipping chunk") - decoder.releaseOutputBuffer(outputBufferIndex, false) - outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) + activeDecoder.releaseOutputBuffer(outputBufferIndex, false) + outputBufferIndex = activeDecoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) continue } pcmData.addAll(byteBufferToFloatArray(outputBuffer, format).asList()) - decoder.releaseOutputBuffer(outputBufferIndex, false) + activeDecoder.releaseOutputBuffer(outputBufferIndex, false) // If we already have enough samples, exit the inner loop if (pcmData.size >= requiredSamples) break - outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) + outputBufferIndex = activeDecoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) } } - decoder.stop() - decoder.release() - extractor.release() + activeDecoder.stop() Timber.tag("AudioDecoder").d("Successfully decoded ${pcmData.size} samples.") @@ -91,6 +90,10 @@ object AudioDecoder { // Return the array at the exact size pcmData.toFloatArray().copyOf(requiredSamples) + }.also { + // Release native resources on both success and failure paths. + runCatching { decoder?.release() } + runCatching { extractor?.release() } } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AudioFileProvider.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AudioFileProvider.kt index 4517da7..a2fb27d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AudioFileProvider.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AudioFileProvider.kt @@ -17,59 +17,65 @@ object AudioFileProvider { private const val TIMEOUT_US = 1000L suspend fun getWavFile(context: Context, uri: Uri): Result = withContext(Dispatchers.IO) { + var extractor: MediaExtractor? = null + var decoder: MediaCodec? = null + var fileOutputStream: FileOutputStream? = null + var tempWavFile: File? = null runCatching { - val extractor = MediaExtractor() - extractor.setDataSource(context, uri, null) - val trackIndex = findAudioTrack(extractor) + extractor = MediaExtractor().also { it.setDataSource(context, uri, null) } + val mediaExtractor = extractor!! + val trackIndex = findAudioTrack(mediaExtractor) if (trackIndex == -1) { - extractor.release() error("No audio track found.") } - extractor.selectTrack(trackIndex) - val format = extractor.getTrackFormat(trackIndex) + mediaExtractor.selectTrack(trackIndex) + val format = mediaExtractor.getTrackFormat(trackIndex) val mime = format.getString(MediaFormat.KEY_MIME) ?: error("MIME type not found.") - val decoder = MediaCodec.createDecoderByType(mime) - decoder.configure(format, null, null, 0) - decoder.start() - - val tempWavFile = File.createTempFile("input_mono", ".wav", context.cacheDir) - val fileOutputStream = FileOutputStream(tempWavFile) + decoder = MediaCodec.createDecoderByType(mime) + val mediaDecoder = decoder!! + mediaDecoder.configure(format, null, null, 0) + mediaDecoder.start() + + tempWavFile = File.createTempFile("input_mono", ".wav", context.cacheDir) + val outFile = tempWavFile!! + fileOutputStream = FileOutputStream(outFile) + val outputStream = fileOutputStream!! // Write an empty WAV header (for 1 channel, mono) val wavHeader = WavHeader(0, 0, 0, 0, 1) - fileOutputStream.write(wavHeader.asByteArray()) + outputStream.write(wavHeader.asByteArray()) var totalBytesWritten = 0 val bufferInfo = MediaCodec.BufferInfo() var isEndOfStream = false while (!isEndOfStream) { - val inputBufferIndex = decoder.dequeueInputBuffer(TIMEOUT_US) + val inputBufferIndex = mediaDecoder.dequeueInputBuffer(TIMEOUT_US) if (inputBufferIndex >= 0) { - val inputBuffer = decoder.getInputBuffer(inputBufferIndex) + val inputBuffer = mediaDecoder.getInputBuffer(inputBufferIndex) if (inputBuffer == null) { Timber.tag("AudioFileProvider").w("Decoder input buffer was null, ending decode early") - decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + mediaDecoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) isEndOfStream = true continue } - val sampleSize = extractor.readSampleData(inputBuffer, 0) + val sampleSize = mediaExtractor.readSampleData(inputBuffer, 0) if (sampleSize < 0) { - decoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + mediaDecoder.queueInputBuffer(inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) isEndOfStream = true } else { - decoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, extractor.sampleTime, 0) - extractor.advance() + mediaDecoder.queueInputBuffer(inputBufferIndex, 0, sampleSize, mediaExtractor.sampleTime, 0) + mediaExtractor.advance() } } - var outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) + var outputBufferIndex = mediaDecoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) while (outputBufferIndex >= 0) { - val outputBuffer = decoder.getOutputBuffer(outputBufferIndex) + val outputBuffer = mediaDecoder.getOutputBuffer(outputBufferIndex) if (outputBuffer == null) { Timber.tag("AudioFileProvider").w("Decoder output buffer was null, skipping chunk") - decoder.releaseOutputBuffer(outputBufferIndex, false) - outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) + mediaDecoder.releaseOutputBuffer(outputBufferIndex, false) + outputBufferIndex = mediaDecoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) continue } val chunk = ByteArray(bufferInfo.size) @@ -77,18 +83,16 @@ object AudioFileProvider { // --- CONVERSION TO MONO --- val monoChunk = stereoToMono(chunk) - fileOutputStream.write(monoChunk) + outputStream.write(monoChunk) totalBytesWritten += monoChunk.size - decoder.releaseOutputBuffer(outputBufferIndex, false) - outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) + mediaDecoder.releaseOutputBuffer(outputBufferIndex, false) + outputBufferIndex = mediaDecoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US) } } - fileOutputStream.close() - decoder.stop() - decoder.release() - extractor.release() + outputStream.close() + fileOutputStream = null val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) val finalHeader = WavHeader( @@ -98,10 +102,21 @@ object AudioFileProvider { bitsPerSample = 16, numChannels = 1 // MONO ) - finalHeader.updateHeader(tempWavFile) - - Timber.tag("AudioFileProvider").d("Mono WAV file created at: ${tempWavFile.absolutePath}") - tempWavFile + finalHeader.updateHeader(outFile) + + Timber.tag("AudioFileProvider").d("Mono WAV file created at: ${outFile.absolutePath}") + outFile + }.also { result -> + // Release native codec/extractor and the stream regardless of success or failure. + fileOutputStream?.let { runCatching { it.close() } } + decoder?.let { + runCatching { it.stop() } + runCatching { it.release() } + } + extractor?.let { runCatching { it.release() } } + if (result.isFailure) { + tempWavFile?.let { runCatching { it.delete() } } + } } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/Extensions.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/Extensions.kt index 627627a..f0d6b79 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/Extensions.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/Extensions.kt @@ -12,21 +12,79 @@ fun Color.toHexString(): String { return String.format(Locale.US, "#%08X", this.toArgb()) } +/** + * Unicode replacement character (U+FFFD) — the universal "this byte could not be decoded" + * marker. + */ +private const val REPLACEMENT_CHAR = '\uFFFD' + +/** + * The 27 "special" characters Windows-1252 assigns to bytes 0x80-0x9F (curly quotes, + * dashes, the euro sign, OE/Z ligatures, etc.). When the trailing byte of a misdecoded + * UTF-8 sequence lands in that range it surfaces as one of these glyphs, so they belong in + * the mojibake "second character" set alongside the U+0080-U+00BF Latin-1 block. + */ +private const val WINDOWS_1252_SPECIALS = + "€‚ƒ„…†‡ˆ‰Š‹ŒŽ" + + "‘’“”•–—˜™š›œžŸ" + +/** + * Multi-character mojibake signature for UTF-8 text decoded as Windows-1252/ISO-8859-1. A + * UTF-8 byte pair (lead 0xC2/0xC3/0xC5, then 0x80-0xBF) misdecoded this way renders as a + * Latin-1 lead character ('Â', 'Ã' or 'Å') immediately followed by either a + * U+0080-U+00BF character or one of the Windows-1252 special glyphs. + * + * The match always requires the lead char to be followed by a second mojibake character, + * never a bare lead char, so legitimately-encoded UTF-8 text containing a standalone + * accented letter (e.g. 'â' in "âme"/"château", an isolated 'Ã'/'Å') is NOT + * flagged and therefore not re-encoded. Validated to flag none of a corpus of correctly + * encoded accented titles while catching their Windows-1252-misread counterparts. + */ +private val MOJIBAKE_LEAD_REGEX = + Regex("[ÂÃÅ][€-¿$WINDOWS_1252_SPECIALS]") + +/** + * The smart-quote / em-dash family (UTF-8 0xE2 0x80 0xXX) misreads to 'â' followed by a + * Windows-1252 special punctuation glyph, or — when the trailing bytes are decoded as + * ISO-8859-1 rather than Windows-1252 — by a U+0080–U+00BF character. 'â' on its own is a + * legitimate letter, so only these two-character pairs are treated as mojibake. + */ +private val MOJIBAKE_PUNCTUATION_REGEX = + Regex("â[€-¿$WINDOWS_1252_SPECIALS]") + +private fun String.hasMojibakeSignature(): Boolean = + MOJIBAKE_LEAD_REGEX.containsMatchIn(this) || MOJIBAKE_PUNCTUATION_REGEX.containsMatchIn(this) + +/** + * Counts U+FFFD replacement characters. Fewer replacement chars after a re-encode means + * the candidate is more likely to be the intended text. + */ +private fun String.replacementCharCount(): Int = this.count { it == REPLACEMENT_CHAR } + /** * Attempts to fix incorrectly encoded metadata strings that frequently appear when * tags are saved using Windows-1252/ISO-8859-1 but are later read as UTF-8. This results - * in characters such as "Ã", "â" or replacement symbols appearing instead of expected - * punctuation. The function re-encodes the text when those patterns are detected and - * removes stray control characters while keeping the original text when no adjustment - * is necessary. + * in characters such as "é", "…" or replacement symbols appearing instead of expected + * punctuation. The function re-encodes the text when those multi-byte mojibake patterns + * are detected and removes stray control characters while keeping the original text when + * no adjustment is necessary. + * + * The detection deliberately requires a multi-character mojibake signature (or an explicit + * U+FFFD replacement char) rather than a single bare accented character, so genuinely + * UTF-8 metadata containing lone accented letters (French "âme"/"château", Portuguese, + * Vietnamese, etc.) is left untouched. The re-encoded candidate is also only preferred + * when it does not increase the number of replacement characters, so a misfire cannot + * corrupt correctly-encoded text. */ fun String?.normalizeMetadataText(): String? { if (this == null) return null val trimmed = this.trim() if (trimmed.isEmpty()) return trimmed - val suspiciousPatterns = listOf("Ã", "â", "�", "ð", "Ÿ") - val needsFix = suspiciousPatterns.any { trimmed.contains(it) } + // Only treat the text as mis-encoded when it carries a multi-byte mojibake signature + // (or an explicit U+FFFD replacement char), so genuine UTF-8 strings containing a lone + // accented character such as 'â' ("âme"/"château") are left untouched. + val needsFix = trimmed.contains(REPLACEMENT_CHAR) || trimmed.hasMojibakeSignature() val reencoded = if (needsFix) { runCatching { @@ -34,7 +92,12 @@ fun String?.normalizeMetadataText(): String? { }.getOrNull() } else null - val candidate = reencoded?.takeIf { it.isNotEmpty() } ?: trimmed + // Prefer the re-encoded candidate only when it is actually an improvement: non-empty + // and not introducing more replacement characters than the original. Otherwise keep the + // original text rather than risk corrupting legitimately-encoded metadata. + val candidate = reencoded + ?.takeIf { it.isNotEmpty() && it.replacementCharCount() <= trimmed.replacementCharCount() } + ?: trimmed val cleaned = candidate.replace("\u0000", "") @@ -192,7 +255,7 @@ fun String.extractArtistsFromTitle( /** * Joins a list of artist names into a display string. - * + * * @param separator The separator to use between artist names (default: ", ") * @return A formatted string with all artist names joined. */ diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/LyricsUtils.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/LyricsUtils.kt index cf1888b..28e9074 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/LyricsUtils.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/LyricsUtils.kt @@ -134,13 +134,13 @@ object MultiLangRomanizer { } } - private fun isRussian(text: String) = text.any { RUSSIAN_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { RUSSIAN_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } - private fun isUkrainian(text: String) = text.any { UKRAINIAN_CYRILLIC_LETTERS.contains(it.toString()) || UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { UKRAINIAN_CYRILLIC_LETTERS.contains(it.toString()) || UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } - private fun isSerbian(text: String) = text.any { SERBIAN_CYRILLIC_LETTERS.contains(it.toString()) || SERBIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { SERBIAN_CYRILLIC_LETTERS.contains(it.toString()) || SERBIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } - private fun isBulgarian(text: String) = text.any { BULGARIAN_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { BULGARIAN_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } - private fun isBelarusian(text: String) = text.any { BELARUSIAN_CYRILLIC_LETTERS.contains(it.toString()) || BELARUSIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { BELARUSIAN_CYRILLIC_LETTERS.contains(it.toString()) || BELARUSIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } - private fun isKyrgyz(text: String) = text.any { KYRGYZ_CYRILLIC_LETTERS.contains(it.toString()) || KYRGYZ_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { KYRGYZ_CYRILLIC_LETTERS.contains(it.toString()) || KYRGYZ_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } - private fun isMacedonian(text: String) = text.any { MACEDONIAN_CYRILLIC_LETTERS.contains(it.toString()) || MACEDONIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { MACEDONIAN_CYRILLIC_LETTERS.contains(it.toString()) || MACEDONIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || !it.toString().matches("[\\u0400-\\u04FF]".toRegex()) } + private fun isRussian(text: String) = text.any { RUSSIAN_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { RUSSIAN_CYRILLIC_LETTERS.contains(it.toString()) || it !in 'Ѐ'..'ӿ' } + private fun isUkrainian(text: String) = text.any { UKRAINIAN_CYRILLIC_LETTERS.contains(it.toString()) || UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { UKRAINIAN_CYRILLIC_LETTERS.contains(it.toString()) || UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || it !in 'Ѐ'..'ӿ' } + private fun isSerbian(text: String) = text.any { SERBIAN_CYRILLIC_LETTERS.contains(it.toString()) || SERBIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { SERBIAN_CYRILLIC_LETTERS.contains(it.toString()) || SERBIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || it !in 'Ѐ'..'ӿ' } + private fun isBulgarian(text: String) = text.any { BULGARIAN_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { BULGARIAN_CYRILLIC_LETTERS.contains(it.toString()) || it !in 'Ѐ'..'ӿ' } + private fun isBelarusian(text: String) = text.any { BELARUSIAN_CYRILLIC_LETTERS.contains(it.toString()) || BELARUSIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { BELARUSIAN_CYRILLIC_LETTERS.contains(it.toString()) || BELARUSIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || it !in 'Ѐ'..'ӿ' } + private fun isKyrgyz(text: String) = text.any { KYRGYZ_CYRILLIC_LETTERS.contains(it.toString()) || KYRGYZ_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { KYRGYZ_CYRILLIC_LETTERS.contains(it.toString()) || KYRGYZ_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || it !in 'Ѐ'..'ӿ' } + private fun isMacedonian(text: String) = text.any { MACEDONIAN_CYRILLIC_LETTERS.contains(it.toString()) || MACEDONIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) } && text.all { MACEDONIAN_CYRILLIC_LETTERS.contains(it.toString()) || MACEDONIAN_SPECIFIC_CYRILLIC_LETTERS.contains(it.toString()) || it !in 'Ѐ'..'ӿ' } fun romanizeJapanese(japaneseText: String): String? { val tokenizer = kuromojiTokenizer ?: return null diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/MediaItemBuilder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/MediaItemBuilder.kt index d5677a5..7306fbb 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/MediaItemBuilder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/MediaItemBuilder.kt @@ -16,7 +16,7 @@ import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.service.loadArtworkBytesViaCoil import java.io.File import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext object MediaItemBuilder { private const val EXTERNAL_MEDIA_ID_PREFIX = "external:" @@ -110,7 +110,13 @@ object MediaItemBuilder { .build() } - fun buildForExternalController(context: Context, song: Song): MediaItem { + suspend fun buildForExternalController(context: Context, song: Song): MediaItem { + val exposedArtworkUri = externalControllerArtworkUri(context, song.albumArtUriString) + val artworkData = exposedArtworkUri?.let { artworkUri -> + withContext(Dispatchers.IO) { + loadArtworkBytesViaCoil(context, artworkUri) + } + } return MediaItem.Builder() .setMediaId(song.id) .setUri(playbackUri(song)) @@ -118,8 +124,8 @@ object MediaItemBuilder { .setMediaMetadata( buildMediaMetadataForSong( song = song, - context = context, - exposedArtworkUri = externalControllerArtworkUri(context, song.albumArtUriString) + exposedArtworkUri = exposedArtworkUri, + artworkData = artworkData ) ) .build() @@ -269,8 +275,8 @@ object MediaItemBuilder { @OptIn(UnstableApi::class) private fun buildMediaMetadataForSong( song: Song, - context: Context? = null, - exposedArtworkUri: Uri? = artworkUri(song.albumArtUriString) + exposedArtworkUri: Uri? = artworkUri(song.albumArtUriString), + artworkData: ByteArray? = null ): MediaMetadata { val metadataBuilder = MediaMetadata.Builder() .setTitle(song.title) @@ -279,12 +285,8 @@ object MediaItemBuilder { exposedArtworkUri?.let { artworkUri -> metadataBuilder.setArtworkUri(artworkUri) - context?.let { appContext -> - runBlocking(Dispatchers.IO) { - loadArtworkBytesViaCoil(appContext, artworkUri) - }?.let { artworkData -> - metadataBuilder.setArtworkData(artworkData, PICTURE_TYPE_FRONT_COVER) - } + artworkData?.let { data -> + metadataBuilder.setArtworkData(data, PICTURE_TYPE_FRONT_COVER) } } diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 632afaa..63ad0d6 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -36,4 +36,9 @@ %d time %d times + + + %d song + %d songs + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e534d0e..30cda2c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -233,4 +233,36 @@ Scan your library for tracks that appear more than once. Scan + + You\'re Offline + Please check your internet connection to continue. + Try Again + You need an internet connection to play this uncached song. + + + Connecting to device… + Preparing playback… + Loading audio… + + + Secure storage is unavailable on this device, so your Navidrome credentials are saved unencrypted. + + + Library sync + Syncing your music library… + + + Syncing all playlists and songs… + Synced %1$d playlists, %2$d songs + Synced %1$d playlists, %2$d songs (%3$d failed) + Syncing playlists… + Synced %1$d playlists + Syncing songs… + Synced %1$d songs + Sync failed: %1$s + network error + authentication failed + server unavailable + unknown error + diff --git a/app/src/main/res/values/strings_presentation_batch_g.xml b/app/src/main/res/values/strings_presentation_batch_g.xml index 0e25660..0d353bd 100644 --- a/app/src/main/res/values/strings_presentation_batch_g.xml +++ b/app/src/main/res/values/strings_presentation_batch_g.xml @@ -518,4 +518,14 @@ Backslash (\) can be used to escape character delimiters. Release logging is tightened so HTTP request headers and raw Android logs do not bypass the Timber release filter. Also includes smart playlist persistence, duplicate-track scanning, playback speed control, clearer playback/sync failure messages, and improved accessibility. + + + Preparing folders… + Loading folders… + This can take a moment while PixelPlayerOSS scans the available subfolders. + Scanning… + 99+ songs + Excluded + Included + Internal storage diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/TrustedMediaItemsResolutionTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/TrustedMediaItemsResolutionTest.kt index 8a50455..086a29e 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/TrustedMediaItemsResolutionTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/service/TrustedMediaItemsResolutionTest.kt @@ -3,6 +3,7 @@ package com.lostf1sh.pixelplayeross.data.service import android.net.Uri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test @@ -20,10 +21,12 @@ class TrustedMediaItemsResolutionTest { artworkUri = "content://com.lostf1sh.pixelplayeross.provider/cache/album.png" ) - val resolution = resolveMediaItemsWithTrustedArtworkGrants( - requestedItems = listOf(attackerSuppliedItem, trustedItem) - ) { mediaId -> - if (mediaId == trustedItem.mediaId) trustedItem else null + val resolution = runBlocking { + resolveMediaItemsWithTrustedArtworkGrants( + requestedItems = listOf(attackerSuppliedItem, trustedItem) + ) { mediaId -> + if (mediaId == trustedItem.mediaId) trustedItem else null + } } assertSame(attackerSuppliedItem, resolution.mediaItems[0]) @@ -39,13 +42,15 @@ class TrustedMediaItemsResolutionTest { val trustedFirst = mediaItem("song-1") val trustedSecond = mediaItem("song-2") - val resolution = resolveMediaItemsWithTrustedArtworkGrants( - requestedItems = listOf(requestedFirst, requestedSecond, requestedThird) - ) { mediaId -> - when (mediaId) { - trustedFirst.mediaId -> trustedFirst - trustedSecond.mediaId -> trustedSecond - else -> null + val resolution = runBlocking { + resolveMediaItemsWithTrustedArtworkGrants( + requestedItems = listOf(requestedFirst, requestedSecond, requestedThird) + ) { mediaId -> + when (mediaId) { + trustedFirst.mediaId -> trustedFirst + trustedSecond.mediaId -> trustedSecond + else -> null + } } } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/library/LibraryTabIdTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/library/LibraryTabIdTest.kt deleted file mode 100644 index 76ff0a2..0000000 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/library/LibraryTabIdTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.lostf1sh.pixelplayeross.presentation.library - -import com.lostf1sh.pixelplayeross.data.model.SortOption -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertIterableEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class LibraryTabIdTest { - - @Test - fun `decodeLibraryTabOrder returns default order when stored value is null`() { - val order = decodeLibraryTabOrder(null) - assertIterableEquals(LibraryTabId.defaultOrder, order) - } - - @Test - fun `decodeLibraryTabOrder preserves known order and restores missing tabs`() { - val storedKeys = listOf( - LibraryTabId.Liked.stableKey, - "UNKNOWN", - LibraryTabId.Playlists.stableKey, - LibraryTabId.Liked.stableKey // duplicate should be ignored - ) - val order = decodeLibraryTabOrder(Json.encodeToString(storedKeys)) - - assertEquals(LibraryTabId.Liked, order.first(), "First entry should match stored stable key") - assertTrue(order.containsAll(LibraryTabId.defaultOrder), "All default tabs should be present exactly once") - assertEquals(LibraryTabId.defaultOrder.size, order.size) - } - - @Test - fun `sort associations remain tied to tab ids after reordering`() { - val persistedSorts = LibraryTabId.defaultOrder.associateWith { tab -> - tab.sortOptions.firstOrNull() ?: SortOption.SongTitleAZ - } - - val shuffledOrder = decodeLibraryTabOrder( - Json.encodeToString( - listOf( - LibraryTabId.Folders.stableKey, - LibraryTabId.Songs.stableKey, - LibraryTabId.Playlists.stableKey - ) - ) - ) - - shuffledOrder.forEach { tab -> - assertEquals(persistedSorts[tab], persistedSorts.getValue(tab)) - } - } -}