diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 24711c4a..7cd04ce3 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,11 +6,11 @@ runs: steps: # Setup the SDKs - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 with: python-version: '3.14' - name: Setup JDK - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 with: distribution: zulu java-version: '21' diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3bd2e54d..cafd4889 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -77,7 +77,7 @@ jobs: echo "SHA256 checksums:" find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum - name: Upload mapping files - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: mapping path: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9e518406..e423cccf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -20,7 +20,7 @@ jobs: with: fetch-depth: 0 # Need the tags to build - name: Setup Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 with: python-version: '3.14' - name: Run pre-commit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4822ce7e..5b108ce5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,14 +59,14 @@ jobs: echo "SHA256 checksums:" find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum - name: Upload AAB - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: AAB path: | app/build/outputs/bundle/**/*.aab compression-level: 0 - name: Upload mapping files - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: mapping path: | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6178f8ea..96883e40 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -315,6 +315,7 @@ dependencies { implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) implementation(libs.ass.media) + implementation(libs.exoplayerhdrutils) implementation(libs.coil.core) implementation(libs.coil.compose) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index cddfb043..b2a0c548 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -22,3 +22,6 @@ -keep class androidx.media3.ui.SubtitlePainter { private final float outlineWidth; } + +# Keep external/native functions +-keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; } diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json new file mode 100644 index 00000000..3e874e4e --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json @@ -0,0 +1,834 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "b297c087485321c3ee8282ad1ff675a0", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "SeriesTrackChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentType", + "columnName": "parentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackFlags", + "columnName": "trackFlags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "codec", + "columnName": "codec", + "affinity": "TEXT" + }, + { + "fieldPath": "relativeTrackIndex", + "columnName": "relativeTrackIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "audioChannels", + "columnName": "audioChannels", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "parentId", + "type" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "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, 'b297c087485321c3ee8282ad1ff675a0')" + ] + } +} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json new file mode 100644 index 00000000..4a41a052 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json @@ -0,0 +1,841 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "1f2fe7912f9e3ab83b77029e07488e84", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', `c_homeSettingsSource` TEXT NOT NULL DEFAULT 'UNSET', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + }, + { + "fieldPath": "config.homeSettingsSource", + "columnName": "c_homeSettingsSource", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'UNSET'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "SeriesTrackChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentType", + "columnName": "parentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackFlags", + "columnName": "trackFlags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "codec", + "columnName": "codec", + "affinity": "TEXT" + }, + { + "fieldPath": "relativeTrackIndex", + "columnName": "relativeTrackIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "audioChannels", + "columnName": "audioChannels", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "parentId", + "type" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "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, '1f2fe7912f9e3ab83b77029e07488e84')" + ] + } +} diff --git a/app/src/appstore/AndroidManifest.xml b/app/src/appstore/AndroidManifest.xml index 8a762a28..e024ca83 100644 --- a/app/src/appstore/AndroidManifest.xml +++ b/app/src/appstore/AndroidManifest.xml @@ -3,5 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/app/src/firetv/AndroidManifest.xml b/app/src/firetv/AndroidManifest.xml new file mode 100644 index 00000000..8a762a28 --- /dev/null +++ b/app/src/firetv/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5be4d55a..aad5c5b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> + { - val current = serverRepository.current.value - val destination = - if (current != null) { - SetupDestination.UserList(current.server) - } else { - SetupDestination.ServerList - } - setupNavigationManager.navigateTo(destination) - Timber.e("Error parsing intent: %s", result.message) - showToast(context, result.message) - return@withLock - } + handleAppUpgrade() + } catch (ex: Exception) { + Timber.e(ex, "Error during app upgrade!") + showToast( + context, + "Error during app upgrade: ${ex.localizedMessage}", + ) + } - is IntentResult.Target -> { - val current = serverRepository.current.value - if (current != null) { - Timber.i("Received valid intent, switching to AppContent") - - if (result.addHomeToBackStack) { - navigationManager.reloadHome() - } else { - navigationManager.backStack.clear() - } - navigationManager.backStack.addAll(result.destinations) - - setupNavigationManager.navigateTo( - SetupDestination.AppContent(current), - ) - } else { - // This should never happen, but just reset app state if it does - setupNavigationManager.navigateTo(SetupDestination.ServerList) - Timber.e("Error parsing intent, no user is active") - showToast(context, "An error occurred parsing the intent") - } - return@withLock - } - - IntentResult.NoOp, - null, - -> { - // No-op, proceed below - } + try { + val result = handleIntent(intent) + if (result) { + Timber.i("appStart was handled by intent") + return@withLock } } catch (ex: Exception) { Timber.e(ex, "Error parsing intent") } try { - val needUpgrade = appUpgradeHandler.needUpgrade() - if (needUpgrade) { - showToast( - context, - context.getString( - R.string.updated_toast, - appUpgradeHandler.currentVersion.toString(), - ), - ) - appUpgradeHandler.run() - } - appUpgradeHandler.copySubfont(false) - val prefs = - preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - val profileProtected = - serverRepository.current.value - ?.user - ?.isProtected == true - if (prefs.signInAutomatically && !profileProtected) { - val current = - serverRepository.restoreSession( - prefs.currentServerId?.toUUIDOrNull(), - prefs.currentUserId?.toUUIDOrNull(), - ) - if (current != null) { - if (current.user.isProtected) { - setupNavigationManager.navigateTo( - SetupDestination.UserList( - current.server, - ), - ) - } else { - // Restored - setupNavigationManager.navigateTo( - SetupDestination.AppContent( - current, - ), - ) - } - } else { - // Did not restore - setupNavigationManager.navigateTo(SetupDestination.ServerList) - } - } else { - setupNavigationManager.navigateTo(SetupDestination.Loading) - backdropService.clearBackdrop() - val currentServerId = prefs.currentServerId?.toUUIDOrNull() - if (currentServerId != null) { - val currentServer = - serverRepository.serverDao.getServer(currentServerId)?.server - if (currentServer != null) { - setupNavigationManager.navigateTo( - SetupDestination.UserList( - currentServer, - ), - ) - } else { - setupNavigationManager.navigateTo(SetupDestination.ServerList) - } - } else { - setupNavigationManager.navigateTo(SetupDestination.ServerList) - } - } + handleAppStart() } catch (ex: Exception) { Timber.e(ex, "Error during appStart") setupNavigationManager.navigateTo(SetupDestination.ServerList) @@ -582,6 +488,103 @@ class MainActivityViewModel } } } + + /** + * Handle an [Intent], navigating if needed + * + * @return true if the intent was handled, false if normal app start should proceed + */ + @VisibleForTesting + internal suspend fun handleIntent(intent: Intent?): Boolean { + val result = intent?.let { intentService.parseIntent(intent) } + when (result) { + is IntentResult.Error -> { + val current = serverRepository.current.value + val destination = + if (current != null) { + SetupDestination.UserList(current.server) + } else { + SetupDestination.ServerList + } + setupNavigationManager.navigateTo(destination) + Timber.e("Error parsing intent: %s", result.message) + showToast(context, result.message) + return true + } + + is IntentResult.Target -> { + val current = serverRepository.current.value + if (current != null) { + Timber.i("Received valid intent, switching to AppContent") + + if (result.addHomeToBackStack) { + navigationManager.reloadHome() + } else { + navigationManager.backStack.clear() + } + navigationManager.backStack.addAll(result.destinations) + + setupNavigationManager.navigateTo( + SetupDestination.AppContent(current), + ) + } else { + // This should never happen, but just reset app state if it does + setupNavigationManager.navigateTo(SetupDestination.ServerList) + Timber.e("Error parsing intent, no user is active") + showToast(context, "An error occurred parsing the intent") + } + return true + } + + IntentResult.NoOp, + null, + -> { + // Not handled + return false + } + } + } + + private suspend fun handleAppUpgrade() { + val needUpgrade = appUpgradeHandler.needUpgrade() + if (needUpgrade) { + showToast( + context, + context.getString( + R.string.updated_toast, + appUpgradeHandler.currentVersion.toString(), + ), + ) + appUpgradeHandler.run() + } + appUpgradeHandler.copySubfont(false) + } + + /** + * Handle starting the app including auto sign in and navigating to the right starting page + */ + @VisibleForTesting + internal suspend fun handleAppStart() { + val signInAutomatically = preferences.data.first().signInAutomatically + val currentUser = serverRepository.current.value + if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) { + // Hot reload + Timber.v("Hot reload restoring state") + setupNavigationManager.navigateTo( + SetupDestination.AppContent(currentUser), + ) + } else { + val restoredSession = + if (signInAutomatically) { + Timber.v("Sign in automatically") + serverRepository.restoreLastSession() + } else { + serverRepository.getMostRecentServer() + } + Timber.d("Restore last session result=%s", restoredSession) + setupNavigationManager.navigateTo(restoredSession.destination) + } + } } private val Destination?.isPlayback: Boolean diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index f027bbae..6ec9d4b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -122,5 +122,7 @@ class WholphinApplication : private set val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion + + val newMinimumServerVersion: ServerVersion = ServerVersion(12, 0, 0) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index 57d4a447..3bc17f3c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -22,6 +22,7 @@ import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.ScreensaverService @@ -33,9 +34,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first import okhttp3.OkHttpClient -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -75,8 +74,10 @@ class WholphinDreamService : lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleScope.launchDefault { if (serverRepository.current.value == null) { - val prefs = preferencesDataStore.data.first() - serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull()) + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { + Timber.w("Could not restore user session") + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 6a691723..45af2d65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice import com.github.damontecres.wholphin.ui.components.ViewOptions import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind @@ -48,8 +49,9 @@ import java.util.UUID SeerrServer::class, SeerrUser::class, RememberedTab::class, + SeriesTrackChoice::class, ], - version = 35, + version = 41, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -68,6 +70,8 @@ import java.util.UUID AutoMigration(32, 33), AutoMigration(33, 34), AutoMigration(34, 35), + AutoMigration(35, 40), + AutoMigration(40, 41), ], ) @TypeConverters(Converters::class) @@ -87,6 +91,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun playbackEffectDao(): PlaybackEffectDao abstract fun rememberedTabDao(): RememberedTabDao + + abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index c3afbc33..702ecfdb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.data import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemTrackModification -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.RelativeMediaStream +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.withContext @@ -28,7 +31,7 @@ class ItemPlaybackRepository constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, - private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, private val streamChoiceService: StreamChoiceService, ) { /** @@ -45,54 +48,79 @@ class ItemPlaybackRepository ): ChosenStreams? = serverRepository.currentUser?.let { user -> val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) - Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}") - return getChosenItemFromPlayback(item, itemPlayback, plc, prefs) + return getChosenItemFromPlayback(item, itemPlayback, prefs) } + suspend fun getChosenItemFromPlayback( + item: BaseItem, + itemPlayback: ItemPlayback?, + prefs: UserPreferences, + ): ChosenStreams? { + val audioStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.AUDIO, + ) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.SUBTITLE, + ) + return getChosenItemFromPlayback(item, itemPlayback, audioStc, subtitleStc, prefs) + } + /** * Get the chosen source & stream for the given item * - * This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null + * This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null */ fun getChosenItemFromPlayback( item: BaseItem, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + audioStc: List, + subtitleStc: List, prefs: UserPreferences, ): ChosenStreams? { + Timber.v( + "For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s", + item.id, + itemPlayback != null, + audioStc.isNotEmpty(), + subtitleStc.isNotEmpty(), + ) val source = item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId } ?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty()) if (source != null) { - val audioStream = + val audioResult = streamChoiceService.chooseAudioStream( candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } .orEmpty(), itemPlayback = itemPlayback, - playbackLanguageChoice = plc, + stc = audioStc, prefs = prefs, ) val subtitleStream = streamChoiceService.chooseSubtitleStream( - audioStreamLang = audioStream?.language, + audioStreamLang = audioResult.stream?.language, candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty(), itemPlayback = itemPlayback, - playbackLanguageChoice = plc, + stc = subtitleStc, prefs = prefs, ) return ChosenStreams( itemPlayback = itemPlayback, - plc = plc, + audioStc = audioStc, + subtitleStc = subtitleStc, itemId = item.id, source = source, videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }, - audioStream = audioStream, + audioStream = audioResult, subtitleStream = subtitleStream, subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED, ) @@ -156,26 +184,36 @@ class ItemPlaybackRepository Timber.v("Saving track selection %s", toSave) toSave = saveItemPlayback(toSave) val seriesId = item.data.seriesId - if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) { + if (seriesId != null || item.data.parentId != null) { if (type == MediaStreamType.AUDIO) { - val stream = source.mediaStreams?.first { it.index == trackIndex } + val stream = + RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) if (stream?.language != null) { - streamChoiceService.updateAudio(item.data, stream.language!!) + streamChoiceService.saveSeriesTrackChoice( + item.data, + stream.type, + stream, + ) } } else if (type == MediaStreamType.SUBTITLE) { if (trackIndex == TrackIndex.DISABLED) { - streamChoiceService.updateSubtitles( + streamChoiceService.saveDisabledSeriesTrackChoice( item.data, - subtitleLang = null, - subtitlesDisabled = true, + MediaStreamType.SUBTITLE, + ) + } else if (trackIndex == TrackIndex.ONLY_FORCED) { + streamChoiceService.saveOnlyForcedSeriesTrackChoice( + item.data, + MediaStreamType.SUBTITLE, ) } else { - val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex } + val stream = + RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) if (stream?.language != null) { - streamChoiceService.updateSubtitles( + streamChoiceService.saveSeriesTrackChoice( item.data, - stream.language!!, - subtitlesDisabled = false, + stream.type, + stream, ) } } @@ -240,9 +278,10 @@ class ItemPlaybackRepository */ suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { Timber.d("deleteChosenStreams: %s", chosenStreams) - chosenStreams?.plc?.let { - Timber.d("Deleting %s", it) - playbackLanguageChoiceDao.delete(it) + val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty() + if (stc.isNotEmpty()) { + Timber.d("Deleting %s", stc) + seriesTrackChoiceDao.delete(stc) } chosenStreams?.itemPlayback?.let { Timber.d("Deleting %s", it) @@ -256,11 +295,12 @@ class ItemPlaybackRepository */ data class ChosenStreams( val itemPlayback: ItemPlayback?, - val plc: PlaybackLanguageChoice?, + val audioStc: List, + val subtitleStc: List, val itemId: UUID, val source: MediaSourceInfo, val videoStream: MediaStream?, - val audioStream: MediaStream?, - val subtitleStream: MediaStream?, + val audioStream: StreamChoiceResult, + val subtitleStream: StreamChoiceResult, val subtitlesDisabled: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt new file mode 100644 index 00000000..5139fb73 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt @@ -0,0 +1,175 @@ +package com.github.damontecres.wholphin.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import com.github.damontecres.wholphin.data.MostRecentServer.None +import com.github.damontecres.wholphin.data.MostRecentServer.Server +import com.github.damontecres.wholphin.data.MostRecentServer.ServerAndUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.toServerString +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID + +/** + * Represents the status of the most recently used server + * + * This can be saved/retrieved from a [MostRecentServerProvider] + */ +sealed interface MostRecentServer { + /** + * There is no recently used server + */ + data object None : MostRecentServer + + /** + * The most recently used server without a recent user + */ + data class Server( + val serverId: UUID, + val serverUrl: String, + ) : MostRecentServer + + /** + * Most recently used server and user + */ + data class ServerAndUser( + val serverId: UUID, + val serverUrl: String, + val userId: UUID, + val accessToken: String, + ) : MostRecentServer { + override fun toString(): String = "ServerAndUser(serverId=$serverId, serverUrl=$serverUrl, userId=$userId)" + } + + companion object { + fun from(current: CurrentUser?): MostRecentServer = + if (current != null && current.user.accessToken.isNotNullOrBlank()) { + ServerAndUser(current.server.id, current.server.url, current.user.id, current.user.accessToken) + } else if (current != null) { + Server(current.server.id, current.server.url) + } else { + None + } + } +} + +interface MostRecentServerProvider { + /** + * Get the [MostRecentServer] from this provider + */ + fun get(): MostRecentServer + + /** + * Save the [CurrentUser] as the most recent server + */ + suspend fun save(current: CurrentUser) + + /** + * Clear the most recent server and user + */ + suspend fun clear() + + /** + * CLear the most recent user, leaving the server + */ + suspend fun clearUser() +} + +/** + * A [MostRecentServerProvider] that stores in [SharedPreferences] + */ +class MostRecentServerSharedPreferences( + context: Context, +) : MostRecentServerProvider { + private val prefs = getServerSharedPreferences(context) + + override fun get(): MostRecentServer { + val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull() + val serverUrl = prefs.getString(SERVER_URL_KEY, null) + val userId = prefs.getString(USER_ID_KEY, null)?.toUUIDOrNull() + val accessToken = prefs.getString(USER_TOKEN_KEY, null) + return if (serverId != null && serverUrl.isNotNullOrBlank() && userId != null && accessToken.isNotNullOrBlank()) { + ServerAndUser(serverId, serverUrl, userId, accessToken) + } else if (serverId != null && serverUrl.isNotNullOrBlank()) { + Server(serverId, serverUrl) + } else { + None + } + } + + override suspend fun save(current: CurrentUser) { + prefs.edit(true) { + putString(SERVER_ID_KEY, current.server.id.toServerString()) + putString(SERVER_URL_KEY, current.server.url) + putString(USER_ID_KEY, current.user.id.toServerString()) + putString(USER_TOKEN_KEY, current.user.accessToken) + } + } + + override suspend fun clear() { + prefs.edit(true) { + remove(SERVER_ID_KEY) + remove(SERVER_URL_KEY) + remove(USER_ID_KEY) + remove(USER_TOKEN_KEY) + } + } + + override suspend fun clearUser() { + prefs.edit(true) { + remove(USER_ID_KEY) + remove(USER_TOKEN_KEY) + } + } + + private fun getServerSharedPreferences(context: Context): SharedPreferences = + context.getSharedPreferences( + "${context.packageName}_server", + Context.MODE_PRIVATE, + ) + + companion object { + private const val SERVER_ID_KEY = "current.server.id" + private const val SERVER_URL_KEY = "current.server.url" + private const val USER_ID_KEY = "current.user.id" + private const val USER_TOKEN_KEY = "current.user.accessToken" + } +} + +/** + * The result of restoring a session + */ +sealed interface RestoredSession { + val destination: SetupDestination + + /** + * Session could not be restored + */ + data object None : RestoredSession { + override val destination: SetupDestination + get() = SetupDestination.ServerList + } + + /** + * Only the server should be restored, so a user still need to be chosen + */ + data class ServerOnly( + val server: JellyfinServer, + ) : RestoredSession { + override val destination: SetupDestination + get() = SetupDestination.UserList(server) + } + + /** + * Session was restored successfully + */ + data class Success( + val currentUser: CurrentUser, + ) : RestoredSession { + override val destination: SetupDestination + get() = SetupDestination.AppContent(currentUser) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt index 5f8c16e6..06835381 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt @@ -8,17 +8,27 @@ import androidx.room.Query import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import java.util.UUID +@Deprecated("Use SeriesTrackChoiceDao") @Dao interface PlaybackLanguageChoiceDao { + @Deprecated("Use SeriesTrackChoiceDao") @Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId") suspend fun get( userId: Int, seriesId: UUID, ): PlaybackLanguageChoice? + @Deprecated("Use SeriesTrackChoiceDao") @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(plc: PlaybackLanguageChoice): Long + @Deprecated("Use SeriesTrackChoiceDao") @Delete fun delete(plc: PlaybackLanguageChoice) + + @Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset") + suspend fun getAll( + limit: Int, + offset: Int, + ): List } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt new file mode 100644 index 00000000..b3126723 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import java.util.UUID + +@Dao +interface SeriesTrackChoiceDao { + @Query( + "SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND (parentId=:seasonId OR parentId=:seriesId) ORDER BY parentId=:seasonId DESC", + ) + suspend fun get( + userId: Int, + seasonId: UUID, + seriesId: UUID, + type: SeriesTrackChoiceType, + ): List + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seasonId") + suspend fun getBySeasonId( + userId: Int, + seasonId: UUID, + type: SeriesTrackChoiceType, + ): List + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seriesId") + suspend fun getBySeriesId( + userId: Int, + seriesId: UUID, + type: SeriesTrackChoiceType, + ): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(stc: SeriesTrackChoice): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(stc: List): List + + @Delete + suspend fun delete(stc: List) + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId") + suspend fun getAll(userId: Int): List + + @Query("SELECT count(*) from SeriesTrackChoice") + suspend fun count(): Int +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 5deba4c3..2c24c8f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -1,15 +1,10 @@ package com.github.damontecres.wholphin.data import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.datastore.core.DataStore import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.ServerUserConfig -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.hilt.IoDispatcher -import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -49,8 +44,8 @@ class ServerRepository val jellyfin: Jellyfin, val serverDao: JellyfinServerDao, val apiClient: ApiClient, - val userPreferencesDataStore: DataStore, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val mostRecentServerProvider: MostRecentServerProvider, ) { private var _current = MutableStateFlow(null) val current: StateFlow = _current @@ -77,6 +72,8 @@ class ServerRepository } } + val serverPluginInstalled = MutableStateFlow(false) + /** * Adds a server to the app database and updated the [ApiClient] to the server's URL * @@ -88,10 +85,11 @@ class ServerRepository } apiClient.update(baseUrl = server.url, accessToken = null) _current.value = null + mostRecentServerProvider.clearUser() } /** - * Saves the server & User to the app database and updates the [ApiClient] to use this server & user + * Saves the server & user to the app database and updates the [ApiClient] to use this server & user */ suspend fun changeUser( server: JellyfinServer, @@ -119,70 +117,101 @@ class ServerRepository ) serverDao.addOrUpdateServer(updatedServer) updatedUser = serverDao.addOrUpdateUser(updatedUser) - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = updatedServer.id.toServerString() - currentUserId = updatedUser.id.toServerString() - }.build() - } + val currentUser = CurrentUser(updatedServer, updatedUser) - withContext(WholphinDispatchers.Main) { - _current.value = currentUser - _currentUserDto.value = ServerUserConfig(userDto) - } - getServerSharedPreferences(context).edit(true) { - putString(SERVER_URL_KEY, updatedServer.url) - putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) - } + mostRecentServerProvider.save(currentUser) + _current.value = currentUser + _currentUserDto.value = ServerUserConfig(userDto) return@withContext currentUser } /** - * Restores a session for the given server & user such as when the app reopens + * Try to restore the most recent session * - * If user has a PIN, this returns false + * Does not check if auto sign-in is enabled, but does check if the user profile is protected */ - suspend fun restoreSession( + suspend fun restoreLastSession(): RestoredSession { + val recentServer = mostRecentServerProvider.get() + Timber.d("restoreLastSession: recentServer=%s", recentServer) + val result = + when (recentServer) { + MostRecentServer.None -> { + RestoredSession.None + } + + is MostRecentServer.Server -> { + val server = serverDao.getServer(recentServer.serverId) + if (server != null) { + RestoredSession.ServerOnly(server.server) + } else { + RestoredSession.None + } + } + + is MostRecentServer.ServerAndUser -> { + val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId) + if (currentUser != null) { + if (currentUser.user.isProtected) { + RestoredSession.ServerOnly(currentUser.server) + } else { + RestoredSession.Success(currentUser) + } + } else { + getMostRecentServerInternal(recentServer) + } + } + } + return result + } + + /** + * Get the most recently used server, if any + */ + suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get()) + + suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession { + val recentServer = recentServer ?: mostRecentServerProvider.get() + val serverId = + when (recentServer) { + MostRecentServer.None -> null + is MostRecentServer.Server -> recentServer.serverId + is MostRecentServer.ServerAndUser -> recentServer.serverId + } + val server = serverId?.let { serverDao.getServer(serverId)?.server } + return if (server != null) { + RestoredSession.ServerOnly(server) + } else { + RestoredSession.None + } + } + + /** + * Try to change to the given server & user. If the user's profile is protected, returns null + * + * @return the server/user or null if the session could not be restored + */ + suspend fun tryChangeUser( serverId: UUID?, userId: UUID?, - ): CurrentUser? { - if (serverId == null || userId == null) { - _current.value = null - return null - } - val serverAndUsers = - withContext(ioDispatcher) { - serverDao.getServer(serverId) - } - if (serverAndUsers != null) { - val current = _current.value - if (current != null && current.server.id == serverId && current.user.id == userId) { - Timber.v("Restoring session for current user, so shortcut") - apiClient.update( - baseUrl = current.server.url, - accessToken = current.user.accessToken, - ) - return current + ): CurrentUser? = + withContext(ioDispatcher) { + return@withContext if (serverId == null || userId == null) { + _current.value = null + null } else { - val user = serverAndUsers.users.firstOrNull { it.id == userId } - if (user != null) { - return changeUser(serverAndUsers.server, user) + val serverAndUsers = serverDao.getServer(serverId) + if (serverAndUsers != null) { + val user = serverAndUsers.users.firstOrNull { it.id == userId } + if (user != null && !user.isProtected) { + changeUser(serverAndUsers.server, user) + } else { + null + } + } else { + null } } } - return null - } - - suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = - withContext(ioDispatcher) { - serverId?.let { serverDao.getServer(serverId)?.server } - } - - fun closeSession() { - _current.value = null - } /** * Given a successful [AuthenticationResult], switch to the user that just authenticated @@ -244,13 +273,7 @@ class ServerRepository withContext(WholphinDispatchers.Main) { _current.value = null } - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentUserId = "" - }.build() - } + mostRecentServerProvider.clearUser() apiClient.update(accessToken = null) } withContext(ioDispatcher) { @@ -263,14 +286,7 @@ class ServerRepository withContext(WholphinDispatchers.Main) { _current.value = null } - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = "" - currentUserId = "" - }.build() - } + mostRecentServerProvider.clear() apiClient.update(baseUrl = null, accessToken = null) } withContext(ioDispatcher) { @@ -279,14 +295,7 @@ class ServerRepository } suspend fun switchServerOrUser() { - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = "" - currentUserId = "" - }.build() - } + mostRecentServerProvider.clear() } suspend fun updateUserAuth( @@ -335,17 +344,6 @@ class ServerRepository if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it } } - - companion object { - fun getServerSharedPreferences(context: Context): SharedPreferences = - context.getSharedPreferences( - "${context.packageName}_server", - Context.MODE_PRIVATE, - ) - - const val SERVER_URL_KEY = "current.server" - const val ACCESS_TOKEN_KEY = "current.accessToken" - } } @Serializable diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 4b38d398..256ccd65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -66,6 +66,7 @@ data class BaseItem( BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ") BaseItemKind.SERIES -> data.seriesProductionYears BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ") + BaseItemKind.MUSIC_ALBUM -> listOf(data.albumArtist ?: artistsString, data.productionYear?.toString()).joinNotBlank(" - ") else -> data.productionYear?.toString() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index 3746092b..91228969 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.VideoType +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest @@ -155,6 +156,22 @@ data class GetItemsFilter( }, ) + fun applyTo(req: GetAlbumArtistsRequest) = + req.copy( + minCommunityRating = minCommunityRating, + isFavorite = favorite, + genreIds = genres, + personIds = persons, + studioIds = studios, + tags = tags, + officialRatings = officialRatings, + years = + buildSet { + years?.letNotEmpty(::addAll) + decades?.forEach { addAll(it..<(it + 10)) } + }, + ) + /** * Merge another [GetItemsFilter] onto this one, replacing only unset values */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 4914248f..39b82bc8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -1,4 +1,7 @@ -@file:UseSerializers(UUIDSerializer::class) +@file:UseSerializers( + UUIDSerializer::class, + DateTimeSerializer::class, +) package com.github.damontecres.wholphin.data.model @@ -7,11 +10,15 @@ import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.data.SortAndDirection +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.serializer.DateTimeSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID @@ -174,19 +181,45 @@ sealed interface HomeRowConfig { } /** - * An arbitrary [GetItemsRequest] allowing to query for anything + * An arbitrary [HomeGetItemsFilter] allowing to query for most anything */ @Serializable @SerialName("GetItems") data class GetItems( val name: String, - val getItems: GetItemsRequest, + val filter: HomeGetItemsFilter, override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions) } + + /** + * Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult. + * Lets third-party plugins (e.g. home-sections) contribute rows without Wholphin needing + * specific knowledge of them. + * + * Headers/Query are a list of [KeyValueEntry] (not a Map) because the server-side plugin + * config is persisted as XML, which doesn't support IDictionary. + */ + @Serializable + @SerialName("CustomEndpoint") + data class CustomEndpoint( + val endpoint: String, + val title: String, + val headers: List? = null, + val query: List? = null, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): CustomEndpoint = this.copy(viewOptions = viewOptions) + } } +@Serializable +data class KeyValueEntry( + val key: String, + val value: String, +) + /** * Root class for home page settings * @@ -241,3 +274,54 @@ data class HomeRowViewOptions( ) } } + +/** + * Subset of [GetItemsFilter] + */ +@Serializable +data class HomeGetItemsFilter( + val sortBy: List? = null, + val sortOrder: List? = null, + val ids: List? = null, + val minCommunityRating: Double? = null, + val minCriticRating: Double? = null, + val recursive: Boolean? = null, + val searchTerm: String? = null, + val parentId: UUID? = null, + val includeItemTypes: List? = null, + val isFavorite: Boolean? = null, + val isPlayed: Boolean? = null, + val officialRatings: Collection? = null, + val tags: List? = null, + val years: List? = null, + val personIds: List? = null, + val minOfficialRating: String? = null, + val hasOfficialRating: Boolean? = null, + val studioIds: List? = null, + val genreIds: List? = null, + val minPremiereDate: LocalDateTime? = null, + val maxPremiereDate: LocalDateTime? = null, +) { + fun asGetItemsRequest() = + GetItemsRequest( + sortBy = sortBy, + sortOrder = sortOrder, + ids = ids, + minCommunityRating = minCommunityRating, + minCriticRating = minCriticRating, + recursive = recursive, + searchTerm = searchTerm, + parentId = parentId, + includeItemTypes = includeItemTypes, + isFavorite = isFavorite, + isPlayed = isPlayed, + officialRatings = officialRatings, + tags = tags, + years = years, + personIds = personIds, + minOfficialRating = minOfficialRating, + hasOfficialRating = hasOfficialRating, + studioIds = studioIds, + genreIds = genreIds, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index 4c54311c..6c5a2a58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -13,6 +13,7 @@ import androidx.room.Relation import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.services.HomePageSettingsSource import com.github.damontecres.wholphin.ui.isNotNullOrBlank import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers @@ -68,6 +69,7 @@ data class JellyfinUser( val lastUsed: ZonedDateTime? = null, val uiLanguage: String? = null, @Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(), + @Embedded(prefix = "c_") val config: JellyfinUserConfig = JellyfinUserConfig(), ) { val hasPin: Boolean get() = pin.isNotNullOrBlank() @@ -77,9 +79,17 @@ data class JellyfinUser( "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " + "accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " + "requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " + - "appPreferences=$appPreferences" + "appPreferences=$appPreferences, config=$config" + + fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config)) } +@Serializable +data class JellyfinUserConfig( + @ColumnInfo(defaultValue = "UNSET") + val homeSettingsSource: HomePageSettingsSource = HomePageSettingsSource.UNSET, +) + /** * Represents the relationship between [JellyfinServer] and its [JellyfinUser] */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt index 4e639009..a20a2dcd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt @@ -35,7 +35,7 @@ data class Person( name = dto.name, role = personRole(context, dto.role, dto.type), type = dto.type, - imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), + imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag), favorite = false, ) @@ -50,7 +50,7 @@ data class Person( name = dto.name, role = personRole(context, dto.role, dto.type), type = dto.type, - imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), + imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag), favorite = favorite, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt index fb54dfcd..f5507a50 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt @@ -10,9 +10,14 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID /** + * DEPRECATED + * * Stores the language choices for a series so they can be applied automatically to other episodes * without the user needing to explicitly choose the tracks + * + * @see SeriesTrackChoice */ +@Deprecated("Use SeriesTrackChoice") @Entity( foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt new file mode 100644 index 00000000..cd2fece7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt @@ -0,0 +1,61 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType + +/** + * A [MediaStream] with its relative track index, its position within a specific stream type + */ +data class RelativeMediaStream( + val relativeIndex: Int, + val stream: MediaStream, +) { + val type: MediaStreamType get() = stream.type + val language: String? get() = stream.language + val isForced: Boolean get() = stream.isForced + val isExternal: Boolean get() = stream.isExternal + val isDefault: Boolean get() = stream.isDefault + val isHearingImpaired: Boolean get() = stream.isHearingImpaired + + companion object { + fun getRelativeIndex( + index: Int, + streams: List, + ): Int? { + val stream = streams.firstOrNull { it.index == index } ?: return null + return getRelativeIndex(stream, streams) + } + + fun getRelativeIndex( + stream: MediaStream, + streams: List, + ): Int? = + streams + .filter { it.type == stream.type && it.isExternal == stream.isExternal } + .sortedBy { it.index } + .indexOfFirstOrNull { it.index == stream.index } + + fun from( + index: Int, + streams: List, + ): RelativeMediaStream? { + val stream = streams.firstOrNull { it.index == index } ?: return null + val relativeIndex = getRelativeIndex(stream, streams) ?: return null + return RelativeMediaStream(relativeIndex, stream) + } + } +} + +/** + * Maps a list of [MediaStream] into [RelativeMediaStream] + */ +fun List.asRelative(): List { + val map = this.groupBy { Pair(it.type, it.isExternal) } + return map.values + .flatMap { typedStreams -> + typedStreams.sortedBy { it.index }.mapIndexed { index, stream -> + RelativeMediaStream(index, stream) + } + }.sortedBy { it.stream.index } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt index aafdebf7..918384d4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt @@ -54,3 +54,11 @@ fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean { permission.parents.any { hasPermission(it) } || SeerrPermission.ADMIN.hasPermission(permissions) } + +val SeerrUserConfig?.canRequestMovies: Boolean + get() = + hasPermission(SeerrPermission.REQUEST_MOVIE) || hasPermission(SeerrPermission.REQUEST_4K_MOVIE) + +val SeerrUserConfig?.canRequestTv: Boolean + get() = + hasPermission(SeerrPermission.REQUEST_TV) || hasPermission(SeerrPermission.REQUEST_4K_TV) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt new file mode 100644 index 00000000..1ff7212b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt @@ -0,0 +1,36 @@ +package com.github.damontecres.wholphin.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface SeerrPluginLogin { + @SerialName("None") + @Serializable + data object None : SeerrPluginLogin + + @SerialName("ApiKey") + @Serializable + data class ApiKey( + val apiKey: String, + ) : SeerrPluginLogin + + @SerialName("Local") + @Serializable + data class Local( + val local: SeerrPluginLocalLogin, + ) : SeerrPluginLogin +} + +@Serializable +data class SeerrPluginLocalLogin( + val username: String, + val password: String, +) + +@Serializable +data class SeerrPluginSettings( + val version: Int = 1, + val serverUrl: String? = null, + val login: SeerrPluginLogin? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt new file mode 100644 index 00000000..5dd313d6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt @@ -0,0 +1,199 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import com.github.damontecres.wholphin.services.isSigns +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID + +/** + * Stores the user's chosen track for a media item. This is by type, ie Audio vs Subtitle tracks + * + * Additional information about the chosen track is stored to compare against tracks in other media + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("userId"), + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["userId", "parentId", "type"], +) +@Serializable +data class SeriesTrackChoice( + val userId: Int, + /** + * Season or Series + */ + val parentId: UUID, + /** + * Type of track, ie audio or subtitle + */ + val type: SeriesTrackChoiceType, + /** + * Origin of the [parentId] + */ + val parentType: TrackChoiceParentType = TrackChoiceParentType.UNKNOWN, + val itemId: UUID? = null, + val language: String? = null, + val activation: ActivationFlag = ActivationFlag.ACTIVATED, + /** + * Flags for the track such as external or forced + * + * @see [TrackFlag] + */ + val trackFlags: Int = 0, + val codec: String? = null, + /** + * The track index based on its position within all streams of the same type + * + * @see [RelativeMediaStream] + */ + val relativeTrackIndex: Int? = null, + val title: String? = null, + val audioChannels: Int? = null, +) { + constructor( + userId: Int, + itemId: UUID, + parentId: UUID, + type: SeriesTrackChoiceType, + parentType: TrackChoiceParentType, + stream: RelativeMediaStream?, + activation: ActivationFlag, + ) : this( + userId = userId, + parentId = parentId, + parentType = parentType, + type = type, + itemId = itemId, + language = stream?.language, + activation = activation, + trackFlags = stream?.let { TrackFlag.calculateTrackFlags(stream.stream) } ?: 0, + codec = stream?.stream?.codec, + relativeTrackIndex = stream?.relativeIndex, + title = stream?.stream?.title, + audioChannels = stream?.stream?.channels, + ) + + companion object { + /** + * Convert the old [PlaybackLanguageChoice] into [SeriesTrackChoice]s + */ + fun from(plc: PlaybackLanguageChoice): List = + buildList { + if (plc.subtitleLanguage != null) { + add( + SeriesTrackChoice( + userId = plc.userId, + parentId = plc.seriesId, + parentType = TrackChoiceParentType.DEPRECATED, + type = SeriesTrackChoiceType.SUBTITLE, + itemId = plc.itemId, + language = plc.subtitleLanguage, + activation = if (plc.subtitlesDisabled == true) ActivationFlag.DISABLED else ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ), + ) + } + if (plc.audioLanguage != null) { + add( + SeriesTrackChoice( + userId = plc.userId, + parentId = plc.seriesId, + parentType = TrackChoiceParentType.DEPRECATED, + type = SeriesTrackChoiceType.AUDIO, + itemId = plc.itemId, + language = plc.audioLanguage, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ), + ) + } + } + } +} + +enum class SeriesTrackChoiceType( + val mediaStreamType: MediaStreamType, +) { + AUDIO(MediaStreamType.AUDIO), + SUBTITLE(MediaStreamType.SUBTITLE), +} + +/** + * Flags for a track such as forced or defect + */ +enum class TrackFlag( + val flag: Int, + val hasFlag: (MediaStream) -> Boolean, +) { + DEFAULT(flag = 1, hasFlag = { it.isDefault }), + FORCED(flag = 2, hasFlag = { it.isForced }), + SDH(flag = 4, hasFlag = { it.isHearingImpaired }), + EXTERNAL(flag = 8, hasFlag = { it.isExternal }), + SIGNS(flag = 16, hasFlag = { isSigns(it) }), + ; + + fun within(flag: Int) = flag and this.flag == this.flag + + companion object { + fun SeriesTrackChoice.has(flag: TrackFlag) = flag.within(trackFlags) + + fun Iterable.calculateFlag() = fold(0) { flag, trackFlag -> flag or trackFlag.flag } + + fun calculateTrackFlags(track: MediaStream): Int = TrackFlag.entries.filter { it.hasFlag.invoke(track) }.calculateFlag() + } +} + +/** + * How was the track activated + */ +enum class ActivationFlag { + ACTIVATED, + DISABLED, + ONLY_FORCED, +} + +/** + * Where the [SeriesTrackChoice.parentId] comes from + */ +enum class TrackChoiceParentType { + /** + * Parent ID is from the series + */ + SERIES, + + /** + * Parent ID is from the season + */ + SEASON, + + /** + * Parent ID is from the deprecated [PlaybackLanguageChoice] + */ + DEPRECATED, + + /** + * Parent ID origin is unknown + */ + UNKNOWN, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 188b7020..63761d99 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.ui.formatMaxBitrate import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup @@ -345,7 +346,7 @@ sealed interface AppPreference { const val MEGA_BIT = 1024 * 1024L const val DEFAULT_BITRATE = 100 * MEGA_BIT - private val bitrateValues = + val bitrateValues = listOf( 500 * 1024L, 750 * 1024L, @@ -378,11 +379,7 @@ sealed interface AppPreference { summarizer = { value -> if (value != null) { val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE - if (v < MEGA_BIT) { - "${v / 1024} kbps" - } else { - "${v / MEGA_BIT} Mbps" - } + formatMaxBitrate(v) } else { null } @@ -456,15 +453,69 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) - val DirectPlayDoviProfile7 = - AppSwitchPreference( - title = R.string.force_dovi_profile_7, - defaultValue = false, - getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL }, + val Hdr10Override = + AppChoicePreference( + title = R.string.hdr10_override, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.hdr10Override }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value } + prefs.updatePlaybackOverrides { hdr10Override = value } }, - summary = R.string.force_dovi_profile_7_summary, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val Hdr10PlusOverride = + AppChoicePreference( + title = R.string.hdr10_plus_override, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.hdr10PlusOverride }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { hdr10PlusOverride = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile5Override = + AppChoicePreference( + title = R.string.dovi_profile_5, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile5Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile5Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile7Override = + AppChoicePreference( + title = R.string.dovi_profile_7, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile7Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile7Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile8Override = + AppChoicePreference( + title = R.string.dovi_profile_8, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile8Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile8Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, ) val DecodeAv1 = @@ -919,6 +970,12 @@ sealed interface AppPreference { destination = Destination.Settings(PreferenceScreenOption.MPV), ) + val HdrFormatSettings = + AppDestinationPreference( + title = R.string.hdr_formats, + destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS), + ) + val MpvHardwareDecoding = AppSwitchPreference( title = R.string.mpv_hardware_decoding, @@ -1183,8 +1240,17 @@ private val ExoPlayerSettings = AppPreference.Ac3Supported, AppPreference.AssSubtitleMode, AppPreference.DirectPlayPgs, - AppPreference.DirectPlayDoviProfile7, AppPreference.DecodeAv1, + AppPreference.HdrFormatSettings, + ) + +private val HdrFormatSettings = + listOf( + AppPreference.Hdr10Override, + AppPreference.Hdr10PlusOverride, + AppPreference.DoviProfile5Override, + AppPreference.DoviProfile7Override, + AppPreference.DoviProfile8Override, ) val ExoPlayerPreferences = @@ -1195,6 +1261,14 @@ val ExoPlayerPreferences = ), ) +val HdrFormatPreferences = + listOf( + PreferenceGroup( + title = R.string.hdr_formats, + preferences = HdrFormatSettings, + ), + ) + private val MpvSettings = listOf( AppPreference.MpvHardwareDecoding, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index cb47ca94..85f8aade 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -186,6 +186,8 @@ class AppPreferencesSerializer disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue preferDolbyVisionOverHdr10Plus = ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue + convertDolbyVisionProfile7 = + ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue }.build() }.build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt index 0647a900..46c75636 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt @@ -48,6 +48,17 @@ object ExperimentalPreference { summary = R.string.prefer_dovi_over_hdr10_plus_summary, ) + val ConvertDolbyVisionProfile7 = + AppSwitchPreference( + title = R.string.convert_dovi_profile_7, + defaultValue = false, + getter = { it.experimentalPreferences.convertDolbyVisionProfile7 }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { convertDolbyVisionProfile7 = value } + }, + summary = R.string.convert_dovi_profile_7_summary, + ) + val PreferAc3ForSurround = AppSwitchPreference( title = R.string.prefer_ac3_for_surround, @@ -93,6 +104,7 @@ val experimentalPreferences = listOf( ExperimentalPreference.VideoTunneling, ExperimentalPreference.PreferDolbyVisionOverHdr10Plus, + ExperimentalPreference.ConvertDolbyVisionProfile7, ExperimentalPreference.PreferAc3ForSurround, ExperimentalPreference.DisableAudioOffload, ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt new file mode 100644 index 00000000..df0b6823 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt @@ -0,0 +1,56 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType + +// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/preference/constant/HdrFormat.kt + +/** + * HDR formats that the user can override in the device profile. + */ +enum class HdrFormat( + val getter: (AppPreferences) -> HdrOverrideMode, + val rangeTypeNames: (jellyfinTenEleven: Boolean) -> Set, +) { + HDR10( + getter = { it.playbackPreferences.overrides.hdr10Override }, + rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10.serialName) }, + ), + HDR10_PLUS( + getter = { it.playbackPreferences.overrides.hdr10PlusOverride }, + rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10_PLUS.serialName) }, + ), + DOVI_PROFILE_5( + getter = { it.playbackPreferences.overrides.doviProfile5Override }, + rangeTypeNames = { setOf(WholphinVideoRangeType.DOVI.serialName) }, + ), + DOVI_PROFILE_7( + getter = { it.playbackPreferences.overrides.doviProfile7Override }, + rangeTypeNames = { jellyfinTenEleven -> + if (jellyfinTenEleven) { + setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) + } else { + emptySet() + } + }, + ), + DOVI_PROFILE_8( + getter = { it.playbackPreferences.overrides.doviProfile8Override }, + rangeTypeNames = { jellyfinTenEleven -> + buildSet { + add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + } + }, + ), +} + +/** + * Collects the video range type names for every [HdrFormat] whose preference is currently set to [mode] + */ +fun AppPreferences.getHdrRangeTypesFor( + mode: HdrOverrideMode, + jellyfinTenEleven: Boolean, +): Set = + HdrFormat.entries + .filter { it.getter(this) == mode } + .flatMapTo(mutableSetOf()) { it.rangeTypeNames(jellyfinTenEleven) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 4910110a..17312378 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -6,14 +6,20 @@ import android.os.Build import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.MostRecentServerProvider +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.RememberedTab +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ExperimentalPreference +import com.github.damontecres.wholphin.preferences.HdrOverrideMode import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences @@ -36,6 +42,7 @@ import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber @@ -56,6 +63,9 @@ class AppUpgradeHandler private val seerrServerDao: SeerrServerDao, private val serverDao: JellyfinServerDao, private val rememberedTabDao: RememberedTabDao, + private val mostRecentServerProvider: MostRecentServerProvider, + private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, ) { val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) @@ -442,5 +452,59 @@ class AppUpgradeHandler } } } + + if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) { + try { + val prefs = appPreferences.data.firstOrNull() + val serverId = prefs?.currentServerId?.toUUIDOrNull() + val userId = prefs?.currentUserId?.toUUIDOrNull() + if (serverId != null && userId != null) { + val server = serverDao.getServer(serverId) + val user = server?.users?.firstOrNull { it.id == userId } + if (server != null && user != null) { + Timber.i("Migrating most recent server") + mostRecentServerProvider.save(CurrentUser(server.server, user)) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error during migration for most recent server") + } + appPreferences.updateData { + it.update { + currentServerId = "" + currentUserId = "" + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("1.0.10"))) { + // directPlayDolbyVisionEL was replaced by doviProfile7Override; carry the setting forward + appPreferences.updateData { + if (it.playbackPreferences.overrides.directPlayDolbyVisionEL) { + it.updatePlaybackOverrides { + doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE + directPlayDolbyVisionEL = false + } + } else { + it + } + } + } + + if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) { + val count = seriesTrackChoiceDao.count() + if (count == 0) { + var offset = 0 + while (true) { + val plcs = playbackLanguageChoiceDao.getAll(100, offset) + if (plcs.isEmpty()) break + offset += plcs.size + + val stcs = plcs.flatMap(SeriesTrackChoice::from) + Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size) + seriesTrackChoiceDao.save(stcs) + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index b9b15f97..13ef5f70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -4,8 +4,12 @@ import android.content.Context import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.ExperimentalPreferences +import com.github.damontecres.wholphin.preferences.HdrFormat +import com.github.damontecres.wholphin.preferences.HdrOverrideMode import com.github.damontecres.wholphin.preferences.PlaybackOverrides import com.github.damontecres.wholphin.preferences.enabled +import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor +import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -61,9 +65,22 @@ class DeviceProfileService downMixAudio = newConfig.overrides.downmixStereo, assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, pgsDirectPlay = newConfig.overrides.directPlayPgs, - dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL, decodeAv1 = prefs.overrides.decodeAv1, preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, + forceEnabledHdr = + buildSet { + appPrefs + .getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven) + .let(::addAll) + // A profile 7 track the player converts to 8.1 is one the server need not transcode + if (DolbyVisionProfile7Conversion.isWanted(newConfig.experimental, mediaCodecCapabilitiesTest)) { + HdrFormat.DOVI_PROFILE_7 + .rangeTypeNames(newConfig.jellyfinTenEleven) + .let(::addAll) + } + }, + forceDisabledHdr = + appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven), jellyfinTenEleven = newConfig.jellyfinTenEleven, maxResolution = newConfig.overrides.maxResolution, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt new file mode 100644 index 00000000..fe462b98 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt @@ -0,0 +1,765 @@ +package com.github.damontecres.wholphin.services + +import androidx.core.net.toUri +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.ServerUserConfig +import com.github.damontecres.wholphin.data.model.createGenreDestination +import com.github.damontecres.wholphin.data.model.createStudioDestination +import com.github.damontecres.wholphin.preferences.HomePagePreferences +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.ui.HomeItemFields +import com.github.damontecres.wholphin.ui.ProgramItemFields +import com.github.damontecres.wholphin.ui.components.getGenreImageMap +import com.github.damontecres.wholphin.ui.formatTypeName +import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.playback.getTypeFor +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetArtistsHandler +import com.github.damontecres.wholphin.util.GetGenresRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.GetProgramsDtoHandler +import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler +import com.github.damontecres.wholphin.util.GetStudiosRequestHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.DateTime +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.GetProgramsDto +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetGenresRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.jellyfin.sdk.model.api.request.GetRecordingsRequest +import org.jellyfin.sdk.model.api.request.GetStudiosRequest +import timber.log.Timber +import java.time.LocalDateTime +import javax.inject.Inject +import javax.inject.Singleton + +@OptIn(ExperimentalSerializationApi::class) +@Singleton +class HomeDataService + @Inject + constructor( + private val api: ApiClient, + @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + private val serverRepository: ServerRepository, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + private val suggestionService: SuggestionService, + ) { + /** + * Fetch the data from the server for a given [HomeRowConfig] + */ + suspend fun fetchDataForRow( + row: HomeRowConfig, + scope: CoroutineScope, + prefs: HomePagePreferences, + userDto: ServerUserConfig, + libraries: List, + limit: Int = prefs.maxItemsPerRow, + isRefresh: Boolean, + usePaging: Boolean = false, + ): HomeRowLoadingState = + when (row) { + is HomeRowConfig.ContinueWatching -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + + Success( + title = ResStringProvider(R.string.continue_watching), + items = resume, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = resume.size >= limit, + ) + } + + is HomeRowConfig.NextUp -> { + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + + Success( + title = ResStringProvider(R.string.next_up), + items = nextUp, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = nextUp.size >= limit, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + val combined = latestNextUpService.buildCombined(resume, nextUp) + + Success( + title = ResStringProvider(R.string.continue_watching), + items = combined.take(limit), + viewOptions = row.viewOptions, + rowType = row, + showViewMore = combined.size >= limit, + ) + } + + is HomeRowConfig.Genres -> { + val request = + GetGenresRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + ) + val items = + GetGenresRequestHandler + .execute(api, request) + .content.items + val genreIds = items.map { it.id } + val genreImages = + getGenreImageMap( + api = api, + userId = serverRepository.currentUser?.id, + scope = scope, + imageUrlService = imageUrlService, + genres = genreIds, + parentId = row.parentId, + includeItemTypes = null, + cardWidthPx = null, + useCache = isRefresh, + ) + val library = + libraries + .firstOrNull { it.itemId == row.parentId } + + val title = + library?.name?.let { ResArgStringProvider(R.string.genres_in, it) } + ?: ResStringProvider(R.string.genres) + val genres = + items.map { + BaseItem( + it, + false, + genreImages[it.id], + createGenreDestination( + genreId = it.id, + genreName = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = library?.includeItemTypes, + collectionType = + library?.collectionType + ?: CollectionType.UNKNOWN, + ), + ) + } + + Success( + title, + genres, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = genres.size >= limit, + ) + } + + is HomeRowConfig.Studios -> { + val request = + GetStudiosRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + includeItemTypes = listOf(BaseItemKind.SERIES), + ) + val items = + GetStudiosRequestHandler + .execute(api, request) + .content.items + val library = + libraries + .firstOrNull { it.itemId == row.parentId } + val title = + library?.name?.let { ResArgStringProvider(R.string.studios_in, it) } + ?: ResStringProvider(R.string.studios) + val studios = + items.map { + val imageUrl = + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.THUMB, + tag = it.imageTags?.get(ImageType.THUMB), + ) + BaseItem( + it, + false, + imageUrl, + createStudioDestination( + studioId = it.id, + name = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = library?.includeItemTypes, + ), + ) + } + + Success( + title, + studios, + rowType = row, + viewOptions = row.viewOptions, + showViewMore = studios.size >= limit, + ) + } + + is HomeRowConfig.RecentlyAdded -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } + val title = getRecentlyAddedTitle(library?.name) + val request = + GetLatestMediaRequest( + fields = library.itemFields, + imageTypeLimit = 1, + parentId = row.parentId, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + latest + } + + is HomeRowConfig.RecentlyReleased -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } + val title = + library?.name?.let { + ResArgStringProvider(R.string.recently_released_in, it) + } ?: ResStringProvider(R.string.recently_released) + val request = + GetItemsRequest( + parentId = row.parentId, + limit = limit, + sortBy = + listOf( + ItemSortBy.PREMIERE_DATE, + ItemSortBy.SERIES_SORT_NAME, + ItemSortBy.AIRED_EPISODE_ORDER, + ), + sortOrder = + listOf( + SortOrder.DESCENDING, + SortOrder.ASCENDING, + SortOrder.DESCENDING, + ), + fields = library.itemFields, + recursive = true, + maxPremiereDate = LocalDateTime.now(), + isUnaired = false, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.ByParent -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } + val request = + GetItemsRequest( + userId = userDto.id, + parentId = row.parentId, + recursive = row.recursive, + sortBy = + row.sort?.let { + buildList { + if (it.sort == ItemSortBy.RANDOM) { + add(ItemSortBy.SORT_NAME) + add(ItemSortBy.RANDOM) + } else { + add(it.sort) + if (it.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } + } + } + }, + sortOrder = + row.sort?.let { + buildList { + if (it.sort == ItemSortBy.RANDOM) { + add(SortOrder.ASCENDING) + add(it.direction) + } else { + add(it.direction) + if (it.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } + } + } + }, + limit = limit, + fields = library.itemFields, + ) + + // Not using getItemName because we want to throw the 404 + val title = + api.userLibraryApi + .getItem( + userId = serverRepository.currentUser?.id, + itemId = row.parentId, + ).content.name + ?.let { StringStringProvider(it) } + ?: ResStringProvider(R.string.collection) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.GetItems -> { + val request = + row.filter.asGetItemsRequest().copy( + userId = userDto.id, + limit = limit, + fields = HomeItemFieldsBoxSets, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + StringStringProvider(row.name), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.Favorite -> { + val title = + ResProviderStringProvider( + R.string.favorite_items_title, + ResStringProvider(formatTypeName(row.kind)), + ) + val resultList = + when (row.kind) { + BaseItemKind.PERSON -> { + val request = + GetPersonsRequest( + userId = userDto.id, + limit = limit, + fields = HomeItemFields, + isFavorite = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + ) + + GetPersonsHandler.execute(api, request).toBaseItems() + } + + BaseItemKind.MUSIC_ARTIST -> { + val request = + GetArtistsRequest( + userId = userDto.id, + limit = limit, + fields = HomeItemFields, + isFavorite = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetArtistsHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetArtistsHandler + .execute(api, request) + .toBaseItems(row.viewOptions.useSeries) + } + } + + else -> { + val fields = + if (row.kind == BaseItemKind.BOX_SET) { + HomeItemFieldsBoxSets + } else { + HomeItemFields + } + val request = + GetItemsRequest( + userId = userDto.id, + recursive = true, + limit = limit, + fields = fields, + includeItemTypes = listOf(row.kind), + isFavorite = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .toBaseItems(row.viewOptions.useSeries) + } + } + } + resultList.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.Recordings -> { + val request = + GetRecordingsRequest( + userId = userDto.id, + isInProgress = true, + fields = HomeItemFields, + limit = limit, + enableImages = true, + enableUserData = true, + enableTotalRecordCount = false, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetRecordingsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getRecordings(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + ResStringProvider(R.string.active_recordings), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.TvPrograms -> { + val request = + GetProgramsDto( + userId = userDto.id, + fields = ProgramItemFields, + limit = limit, + enableUserData = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), + imageTypeLimit = 1, + isAiring = true, + minEndDate = DateTime.now().plusMinutes(1), + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetProgramsDtoHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getPrograms(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + ResStringProvider(R.string.watch_live), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.TvChannels -> { + val request = + GetLiveTvChannelsRequest( + userId = userDto.id, + fields = HomeItemFields, + limit = limit, + enableImages = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetLiveTvChannelsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getLiveTvChannels(request) + .toBaseItems(api, row.viewOptions.useSeries) + }.let { + Success( + ResStringProvider(R.string.channels), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.Suggestions -> { + val library = + api.userLibraryApi + .getItem(itemId = row.parentId) + .content + val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "") + val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) + if (itemKind != null) { + val suggestions = + suggestionService + .getSuggestionsFlow(row.parentId, itemKind) + .firstOrNull() + when (suggestions) { + SuggestionsResource.Empty -> { + Success( + title, + listOf(), + row.viewOptions, + rowType = row, + ) + } + + is SuggestionsResource.Success -> { + Success( + title, + suggestions.items, + row.viewOptions, + rowType = row, + showViewMore = suggestions.items.size >= limit, + ) + } + + SuggestionsResource.Loading, + null, + -> { + HomeRowLoadingState.Loading(title) + } + } + } else { + HomeRowLoadingState.Error( + title = title, + message = "Unsupported type ${library.collectionType}", + ) + } + } + + is HomeRowConfig.CustomEndpoint -> { + val title = StringStringProvider(row.title) + try { + val items = + fetchCustomEndpointItems(row) + .map { BaseItem(it, row.viewOptions.useSeries) } + Success(title, items, row.viewOptions, rowType = row) + } catch (ex: Exception) { + Timber.w(ex, "Custom endpoint %s failed", row.endpoint) + HomeRowLoadingState.Error(title, exception = ex) + } + } + } + + private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List { + val base = + api.baseUrl?.toHttpUrlOrNull() + ?: throw IllegalStateException("Jellyfin baseUrl not set") + val endpointUri = row.endpoint.toUri() + if (!endpointUri.isRelative) { + throw IllegalArgumentException("Custom endpoint must be an path relative to Jellyfin baseUrl: ${row.endpoint}") + } + val params = + buildMap { + serverRepository.currentUser + ?.id + ?.toString() + ?.let { put("userId", it) } + row.query?.forEach { put(it.key, it.value) } + } + val resolved = + base + .resolve(row.endpoint) + ?.newBuilder() + ?.apply { + params.forEach { (k, v) -> addQueryParameter(k, v) } + }?.build() + ?: throw IllegalStateException("Could not resolve endpoint: ${row.endpoint}") + val request = + Request + .Builder() + .url(resolved) + .get() + .apply { + row.headers?.forEach { header(it.key, it.value) } + }.build() + val response = + authOkHttpClient + .newCall(request) + .execute() + return response.use { + if (!it.isSuccessful) { + throw InvalidStatusException(it.code, null) + } + val queryResult = + HomeSettingsService.jsonParser.decodeFromStream(it.body.byteStream()) + Timber.v("Got %s items from custom endpoint", queryResult.items.size) + queryResult.items + } + } + } + +private val Library?.itemFields: List + get() = + if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) { + // Get child count to show in the header + HomeItemFieldsBoxSets + } else { + HomeItemFields + } + +private val Library.includeItemTypes: List? + get() = + collectionType.let { collectionType -> + getTypeFor(collectionType)?.let { type -> listOf(type) } + } + +private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index c070b37e..1c05640e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -2,48 +2,27 @@ package com.github.damontecres.wholphin.services import android.content.Context import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION -import com.github.damontecres.wholphin.data.model.ServerUserConfig -import com.github.damontecres.wholphin.data.model.createGenreDestination -import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration -import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.HomeItemFields -import com.github.damontecres.wholphin.ui.ProgramItemFields -import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.formatTypeName -import com.github.damontecres.wholphin.ui.main.settings.Library -import com.github.damontecres.wholphin.ui.playback.getTypeFor -import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.util.ResArgStringProvider import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider -import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetArtistsHandler -import com.github.damontecres.wholphin.util.GetGenresRequestHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler -import com.github.damontecres.wholphin.util.GetPersonsHandler -import com.github.damontecres.wholphin.util.GetProgramsDtoHandler -import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler -import com.github.damontecres.wholphin.util.GetStudiosRequestHandler -import com.github.damontecres.wholphin.util.HomeRowLoadingState -import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -55,32 +34,15 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.GetProgramsDto -import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.UserDto -import org.jellyfin.sdk.model.api.request.GetArtistsRequest -import org.jellyfin.sdk.model.api.request.GetGenresRequest -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest -import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest -import org.jellyfin.sdk.model.api.request.GetPersonsRequest -import org.jellyfin.sdk.model.api.request.GetRecordingsRequest -import org.jellyfin.sdk.model.api.request.GetStudiosRequest import timber.log.Timber import java.io.File -import java.time.LocalDateTime import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException /** * Handles getting home page settings and data @@ -92,21 +54,11 @@ class HomeSettingsService @param:ApplicationContext private val context: Context, private val api: ApiClient, private val serverRepository: ServerRepository, - private val userPreferencesService: UserPreferencesService, private val navDrawerService: NavDrawerService, - private val latestNextUpService: LatestNextUpService, - private val imageUrlService: ImageUrlService, - private val suggestionService: SuggestionService, private val displayPreferencesService: DisplayPreferencesService, + private val serverPluginApi: ServerPluginApi, + private val serverDao: JellyfinServerDao, ) { - @OptIn(ExperimentalSerializationApi::class) - val jsonParser = - Json { - isLenient = true - ignoreUnknownKeys = true - allowTrailingComma = true - } - /** * The current home page settings */ @@ -188,9 +140,8 @@ class HomeSettingsService /** * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows - * - * This is public only for testing */ + @VisibleForTesting fun decode(element: JsonElement): HomePageSettings { val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) { @@ -211,6 +162,42 @@ class HomeSettingsService return HomePageSettings(rows, version) } + private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? = + try { + block.invoke() + } catch (ex: Exception) { + Timber.w(ex, "Error loading settings") + null + } + + suspend fun fetchSettingsFor( + userId: UUID, + source: HomePageSettingsSource, + ): HomePageResolvedSettings? { + val (resolvedSource, settings) = + when (source) { + HomePageSettingsSource.UNSET -> loadInOrder(userId) + HomePageSettingsSource.LOCAL -> source to loadFromLocal(userId) + HomePageSettingsSource.SERVER_PROFILE -> source to loadFromServer(userId) + HomePageSettingsSource.PLUGIN -> source to serverPluginApi.fetchHomePageSettings() + HomePageSettingsSource.DEFAULT -> source to createDefault(userId).asHomePageSettings() + HomePageSettingsSource.WEB_CONFIG -> source to parseFromWebConfig(userId)?.asHomePageSettings() + } + Timber.i( + "fetch home settings: resolvedSource=%s, userId=%s, found=%s", + resolvedSource, + userId, + settings != null, + ) + return settings?.let { + val resolvedRows = + settings.rows.mapIndexed { index, config -> + resolve(index, config) + } + HomePageResolvedSettings(userId, resolvedSource, resolvedRows) + } + } + /** * Loads [HomePageSettings] into [currentSettings] * @@ -218,55 +205,70 @@ class HomeSettingsService * * Does not persist either the server nor default */ - suspend fun loadCurrentSettings(userId: UUID) { - Timber.v("Getting setting for %s", userId) - // User local then server/remote otherwise create a default + suspend fun loadCurrentSettings(user: JellyfinUser) { + currentSettings.update { HomePageResolvedSettings.EMPTY } + + Timber.v("Getting setting for %s", user.id) val settings = try { - val local = loadFromLocal(userId) - Timber.v("Found local? %s", local != null) - local + fetchSettingsFor(user.id, user.config.homeSettingsSource) + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { - Timber.w(ex, "Error loading local settings") - // TODO show toast? - null - } ?: try { - val remote = loadFromServer(userId) - Timber.v("Found remote? %s", remote != null) - remote - } catch (ex: Exception) { - Timber.w(ex, "Error loading remote settings") + Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource) null } - val resolvedSettings = - if (settings != null) { - Timber.v("Found settings") - // Resolve - val resolvedRows = - settings.rows.mapIndexed { index, config -> - resolve(index, config) - } - HomePageResolvedSettings(userId, resolvedRows) - } else { - createDefault(userId) - } - + Timber.v("Found settings: %s", settings != null) + val resolvedSettings = settings ?: createDefault(user.id) currentSettings.update { resolvedSettings } } + /** + * Tries to load settings from local->server->plugin + */ + private suspend fun loadInOrder(userId: UUID): Pair { + Timber.v("loadInOrder: local") + var settings = + HomePageSettingsSource.LOCAL to + tryLoad { + loadFromLocal(userId) + } + + if (settings.second == null) { + Timber.v("loadInOrder: server") + settings = + HomePageSettingsSource.SERVER_PROFILE to + tryLoad { + loadFromServer(userId) + } + } + + if (settings.second == null && serverRepository.serverPluginInstalled.value) { + Timber.v("loadInOrder: plugin") + settings = HomePageSettingsSource.PLUGIN to + tryLoad { + serverPluginApi.fetchHomePageSettings() + } + } + return settings + } + /** * Resolve the settings and set them to be the current settings */ suspend fun updateCurrent( - userId: UUID, + source: HomePageSettingsSource, settings: HomePageSettings, ) { val resolvedRows = settings.rows.mapIndexed { index, config -> resolve(index, config) } - val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows) - currentSettings.update { resolvedSettings } + currentSettings.update { it.copy(rows = resolvedRows) } + serverRepository.currentUser?.let { user -> + val toSave = user.updateConfig { it.copy(homeSettingsSource = source) } + serverDao.updateUser(toSave) + } } /** @@ -316,7 +318,7 @@ class HomeSettingsService ), ) val rowConfig = continueWatchingRow + includedIds - return HomePageResolvedSettings(userId, rowConfig) + return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig) } /** @@ -432,7 +434,7 @@ class HomeSettingsService null } }.flatten() - HomePageResolvedSettings(userId, rowConfigs) + HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs) } else { null } @@ -560,6 +562,14 @@ class HomeSettingsService config, ) } + + is HomeRowConfig.CustomEndpoint -> { + HomeRowConfigDisplay( + id = id, + title = StringStringProvider(config.title), + config, + ) + } } private suspend fun getItemName( @@ -585,631 +595,16 @@ class HomeSettingsService ResStringProvider(R.string.unknown) } - /** - * Fetch the data from the server for a given [HomeRowConfig] - */ - suspend fun fetchDataForRow( - row: HomeRowConfig, - scope: CoroutineScope, - prefs: HomePagePreferences, - userDto: ServerUserConfig, - libraries: List, - limit: Int = prefs.maxItemsPerRow, - isRefresh: Boolean, - usePaging: Boolean = false, - ): HomeRowLoadingState = - when (row) { - is HomeRowConfig.ContinueWatching -> { - val resume = - latestNextUpService.getResume( - userDto.id, - limit, - true, - row.viewOptions.useSeries, - ) - - Success( - title = ResStringProvider(R.string.continue_watching), - items = resume, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = resume.size >= limit, - ) - } - - is HomeRowConfig.NextUp -> { - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - row.viewOptions.useSeries, - ) - - Success( - title = ResStringProvider(R.string.next_up), - items = nextUp, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = nextUp.size >= limit, - ) - } - - is HomeRowConfig.ContinueWatchingCombined -> { - val resume = - latestNextUpService.getResume( - userDto.id, - limit, - true, - row.viewOptions.useSeries, - ) - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - row.viewOptions.useSeries, - ) - val combined = latestNextUpService.buildCombined(resume, nextUp) - - Success( - title = ResStringProvider(R.string.continue_watching), - items = combined.take(limit), - viewOptions = row.viewOptions, - rowType = row, - showViewMore = combined.size >= limit, - ) - } - - is HomeRowConfig.Genres -> { - val request = - GetGenresRequest( - parentId = row.parentId, - userId = userDto.id, - limit = limit, - ) - val items = - GetGenresRequestHandler - .execute(api, request) - .content.items - val genreIds = items.map { it.id } - val genreImages = - getGenreImageMap( - api = api, - userId = serverRepository.currentUser?.id, - scope = scope, - imageUrlService = imageUrlService, - genres = genreIds, - parentId = row.parentId, - includeItemTypes = null, - cardWidthPx = null, - useCache = isRefresh, - ) - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - - val title = - library?.name?.let { ResArgStringProvider(R.string.genres_in, it) } - ?: ResStringProvider(R.string.genres) - val genres = - items.map { - BaseItem( - it, - false, - genreImages[it.id], - createGenreDestination( - genreId = it.id, - genreName = it.name ?: "", - parentId = row.parentId, - parentName = library?.name, - includeItemTypes = - library?.collectionType?.let { - getTypeFor(it)?.let { - listOf(it) - } - }, - collectionType = - library?.collectionType - ?: CollectionType.UNKNOWN, - ), - ) - } - - Success( - title, - genres, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = genres.size >= limit, - ) - } - - is HomeRowConfig.Studios -> { - val request = - GetStudiosRequest( - parentId = row.parentId, - userId = userDto.id, - limit = limit, - includeItemTypes = listOf(BaseItemKind.SERIES), - ) - val items = - GetStudiosRequestHandler - .execute(api, request) - .content.items - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - val title = - library?.name?.let { ResArgStringProvider(R.string.studios_in, it) } - ?: ResStringProvider(R.string.studios) - val studios = - items.map { - val imageUrl = - imageUrlService.getItemImageUrl( - itemId = it.id, - imageType = ImageType.THUMB, - ) - BaseItem( - it, - false, - imageUrl, - createStudioDestination( - studioId = it.id, - name = it.name ?: "", - parentId = row.parentId, - parentName = library?.name, - includeItemTypes = - library?.collectionType?.let { - getTypeFor(it)?.let { - listOf(it) - } - }, - ), - ) - } - - Success( - title, - studios, - viewOptions = row.viewOptions, - showViewMore = studios.size >= limit, - ) - } - - is HomeRowConfig.RecentlyAdded -> { - val library = libraries.firstOrNull { it.itemId == row.parentId } - val title = getRecentlyAddedTitle(library?.name) - val request = - GetLatestMediaRequest( - fields = library.itemFields, - imageTypeLimit = 1, - parentId = row.parentId, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - latest - } - - is HomeRowConfig.RecentlyReleased -> { - val library = libraries.firstOrNull { it.itemId == row.parentId } - val title = - library?.name?.let { - ResArgStringProvider(R.string.recently_released_in, it) - } ?: ResStringProvider(R.string.recently_released) - val request = - GetItemsRequest( - parentId = row.parentId, - limit = limit, - sortBy = - listOf( - ItemSortBy.PREMIERE_DATE, - ItemSortBy.SERIES_SORT_NAME, - ItemSortBy.AIRED_EPISODE_ORDER, - ), - sortOrder = - listOf( - SortOrder.DESCENDING, - SortOrder.ASCENDING, - SortOrder.DESCENDING, - ), - fields = library.itemFields, - recursive = true, - maxPremiereDate = LocalDateTime.now(), - isUnaired = false, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem.from(it, api, row.viewOptions.useSeries) } - }.let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.ByParent -> { - val library = libraries.firstOrNull { it.itemId == row.parentId } - val request = - GetItemsRequest( - userId = userDto.id, - parentId = row.parentId, - recursive = row.recursive, - sortBy = - row.sort?.let { - buildList { - if (it.sort == ItemSortBy.RANDOM) { - add(ItemSortBy.SORT_NAME) - add(ItemSortBy.RANDOM) - } else { - add(it.sort) - if (it.sort != ItemSortBy.SORT_NAME) { - add(ItemSortBy.SORT_NAME) - } - } - } - }, - sortOrder = - row.sort?.let { - buildList { - if (it.sort == ItemSortBy.RANDOM) { - add(SortOrder.ASCENDING) - add(it.direction) - } else { - add(it.direction) - if (it.sort != ItemSortBy.SORT_NAME) { - add(SortOrder.ASCENDING) - } - } - } - }, - limit = limit, - fields = library.itemFields, - ) - - // Not using getItemName because we want to throw the 404 - val title = - api.userLibraryApi - .getItem( - userId = serverRepository.currentUser?.id, - itemId = row.parentId, - ).content.name - ?.let { StringStringProvider(it) } - ?: ResStringProvider(R.string.collection) - if (usePaging) { - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.GetItems -> { - val request = - row.getItems.let { - if (it.limit == null) { - it.copy( - userId = userDto.id, - limit = limit, - ) - } else { - it.copy( - userId = userDto.id, - ) - } - } - if (usePaging) { - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - StringStringProvider(row.name), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.Favorite -> { - val title = - ResProviderStringProvider( - R.string.favorite_items_title, - ResStringProvider(formatTypeName(row.kind)), - ) - val resultList = - when (row.kind) { - BaseItemKind.PERSON -> { - val request = - GetPersonsRequest( - userId = userDto.id, - limit = limit, - fields = HomeItemFields, - isFavorite = true, - enableImages = true, - enableImageTypes = listOf(ImageType.PRIMARY), - ) - - GetPersonsHandler.execute(api, request).toBaseItems() - } - - BaseItemKind.MUSIC_ARTIST -> { - val request = - GetArtistsRequest( - userId = userDto.id, - limit = limit, - fields = HomeItemFields, - isFavorite = true, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetArtistsHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetArtistsHandler - .execute(api, request) - .toBaseItems(row.viewOptions.useSeries) - } - } - - else -> { - val fields = - if (row.kind == BaseItemKind.BOX_SET) { - HomeItemFieldsBoxSets - } else { - HomeItemFields - } - val request = - GetItemsRequest( - userId = userDto.id, - recursive = true, - limit = limit, - fields = fields, - includeItemTypes = listOf(row.kind), - isFavorite = true, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetItemsRequestHandler - .execute(api, request) - .toBaseItems(row.viewOptions.useSeries) - } - } - } - resultList.let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.Recordings -> { - val request = - GetRecordingsRequest( - userId = userDto.id, - isInProgress = true, - fields = HomeItemFields, - limit = limit, - enableImages = true, - enableUserData = true, - enableTotalRecordCount = false, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetRecordingsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - api.liveTvApi - .getRecordings(request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - ResStringProvider(R.string.active_recordings), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.TvPrograms -> { - val request = - GetProgramsDto( - userId = userDto.id, - fields = ProgramItemFields, - limit = limit, - enableUserData = true, - enableImages = true, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), - imageTypeLimit = 1, - isAiring = true, - minEndDate = DateTime.now().plusMinutes(1), - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetProgramsDtoHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - api.liveTvApi - .getPrograms(request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - ResStringProvider(R.string.watch_live), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.TvChannels -> { - val request = - GetLiveTvChannelsRequest( - userId = userDto.id, - fields = HomeItemFields, - limit = limit, - enableImages = true, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetLiveTvChannelsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - api.liveTvApi - .getLiveTvChannels(request) - .toBaseItems(api, row.viewOptions.useSeries) - }.let { - Success( - ResStringProvider(R.string.channels), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.Suggestions -> { - val library = - api.userLibraryApi - .getItem(itemId = row.parentId) - .content - val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "") - val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) - if (itemKind != null) { - val suggestions = - suggestionService - .getSuggestionsFlow(row.parentId, itemKind) - .firstOrNull() - when (suggestions) { - SuggestionsResource.Empty -> { - Success( - title, - listOf(), - row.viewOptions, - rowType = row, - ) - } - - is SuggestionsResource.Success -> { - Success( - title, - suggestions.items, - row.viewOptions, - rowType = row, - showViewMore = suggestions.items.size >= limit, - ) - } - - SuggestionsResource.Loading, - null, - -> { - HomeRowLoadingState.Loading(title) - } - } - } else { - HomeRowLoadingState.Error( - title = title, - message = "Unsupported type ${library.collectionType}", - ) - } - } - } - companion object { const val CUSTOM_PREF_ID = "home_settings" + + @OptIn(ExperimentalSerializationApi::class) + val jsonParser = + Json { + isLenient = true + ignoreUnknownKeys = true + allowTrailingComma = true + } } } @@ -1229,10 +624,14 @@ data class HomeRowConfigDisplay( */ data class HomePageResolvedSettings( val userId: UUID, + val source: HomePageSettingsSource, val rows: List, ) { + fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + companion object { - val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList()) + val EMPTY = + HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList()) } } @@ -1266,17 +665,6 @@ fun getRecentlyAddedTitle(name: String?): StringProvider = name?.let { ResArgStringProvider(R.string.recently_added_in, it) } ?: ResStringProvider(R.string.recently_added) -private val Library?.itemFields: List - get() = - if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) { - // Get child count to show in the header - HomeItemFieldsBoxSets - } else { - HomeItemFields - } - -private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT) - fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions = when (collectionType) { CollectionType.MUSIC, @@ -1315,3 +703,14 @@ fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOp HomeRowViewOptions() } } + +enum class HomePageSettingsSource( + @param:StringRes val stringResId: Int, +) { + UNSET(R.string.unknown), + LOCAL(R.string.home_settings_source_local), + SERVER_PROFILE(R.string.home_settings_source_server_profile), + PLUGIN(R.string.home_settings_source_plugin), + DEFAULT(R.string.home_settings_source_unset), + WEB_CONFIG(R.string.home_settings_source_web), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 6b71d683..967629da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -35,6 +35,11 @@ class ImageUrlService parentBackdropId: UUID? = null, fillWidth: Int? = null, fillHeight: Int? = null, + seriesPrimaryTag: String? = null, + parentLogoId: UUID? = null, + parentLogoTag: String? = null, + parentThumbTag: String? = null, + parentBackdropTags: List = emptyList(), ): String? = when (imageType) { ImageType.LOGO -> { @@ -44,6 +49,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentLogoTag.takeIf { parentLogoId == seriesId }, ) } else { getItemImageUrl( @@ -51,6 +57,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -63,6 +70,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId }, ) } else if (backdropTags.isNotEmpty()) { getItemImageUrl( @@ -70,6 +78,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = backdropTags.first(), ) } else { null @@ -86,6 +95,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentThumbTag, ) } else if (useSeriesForPrimary && parentBackdropId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) @@ -96,6 +106,7 @@ class ImageUrlService imageType = ImageType.BACKDROP, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentBackdropTags.firstOrNull(), ) } else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { getItemImageUrl( @@ -103,6 +114,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentThumbTag, ) } else if (useSeriesForPrimary && parentThumbId == null && @@ -115,6 +127,7 @@ class ImageUrlService imageType = ImageType.PRIMARY, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[ImageType.PRIMARY], ) } else if (imageType !in imageTags && backdropTags.isNotEmpty()) { // If no thumb, use backdrop if available @@ -123,6 +136,7 @@ class ImageUrlService imageType = ImageType.BACKDROP, fillWidth = fillWidth, fillHeight = fillHeight, + tag = backdropTags.first(), ) } else { getItemImageUrl( @@ -130,6 +144,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -145,6 +160,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY }, ) } else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { getItemImageUrl( @@ -152,6 +168,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY }, ) } else { getItemImageUrl( @@ -159,6 +176,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -169,6 +187,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -193,6 +212,11 @@ class ImageUrlService backdropTags = item.data.backdropImageTags.orEmpty(), fillWidth = fillWidth, fillHeight = fillHeight, + seriesPrimaryTag = item.data.seriesPrimaryImageTag, + parentLogoId = item.data.parentLogoItemId, + parentLogoTag = item.data.parentLogoImageTag, + parentThumbTag = item.data.parentThumbImageTag, + parentBackdropTags = item.data.parentBackdropImageTags.orEmpty(), ) } else { null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt index 2dd0cde5..4fda8260 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services import android.app.SearchManager import android.content.Intent +import androidx.annotation.VisibleForTesting +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds @@ -114,6 +116,7 @@ class IntentService return IntentResult.Target(destinations, true) } + @VisibleForTesting internal suspend fun prepare(intent: Intent): IntentResult? { val appPrefs = userPreferencesService.flow.first().appPreferences @@ -121,39 +124,33 @@ class IntentService val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull() return if (userId != null && serverId != null) { Timber.v("Intent switches user") - val user = serverRepository.serverDao.getUser(serverId, userId) - if (user != null && !user.isProtected) { - serverRepository.restoreSession(serverId, userId) - ?: IntentResult.Error("Error restoring user") + val user = serverRepository.tryChangeUser(serverId, userId) + if (user != null) { null } else { - IntentResult.Error("Cannot switch to specified user") + IntentResult.Error("Error restoring user") } - } else { - val profileProtected = - serverRepository.current.value - ?.user - ?.isProtected == true - if (appPrefs.signInAutomatically && !profileProtected) { - Timber.v("No current user, so restoring last") - val userId = appPrefs.currentUserId?.toUUIDOrNull() - val serverId = appPrefs.currentServerId?.toUUIDOrNull() + } else if (appPrefs.signInAutomatically) { + Timber.v("No current user, so restoring last") + val result = serverRepository.restoreLastSession() + Timber.v("Restored result=%s", result) + when (result) { + RestoredSession.None, + is RestoredSession.ServerOnly, + -> { + IntentResult.Error("Could not auto sign-in, specify a server & user") + } - if (userId != null && serverId != null) { - val current = - serverRepository.restoreSession(serverId, userId) - ?: return IntentResult.Error("Error restoring user") - if (current.user.isProtected) { + is RestoredSession.Success -> { + if (result.currentUser.user.isProtected) { IntentResult.Error("Could not auto sign-in, user is protected") } else { null } - } else { - IntentResult.Error("Could not auto sign-in, specify a server & user") } - } else { - IntentResult.Error("Auto sign-in not enabled or user is protected") } + } else { + IntentResult.Error("Auto sign-in not enabled") } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt index 01ed9dd2..a60bf7c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.hilt.work.HiltWorker import androidx.lifecycle.lifecycleScope @@ -13,6 +14,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.util.ExceptionHandler @@ -52,12 +54,8 @@ class LatestNextUpWorker try { if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { // Not active - var currentUser = serverRepository.current.value - if (currentUser == null) { - serverRepository.restoreSession(serverId, userId) - currentUser = serverRepository.current.value - } - if (currentUser == null) { + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { Timber.w("No user found during run") return Result.failure() } @@ -91,7 +89,7 @@ class LatestNextUpSchedulerService "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", ) - // Exposed for testing + @VisibleForTesting internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO init { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index ec6e9d2c..16ac4edd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -122,7 +122,11 @@ class MusicService mutex.withLock { if (mediaSession == null) { Timber.i("Starting music MediaSession") - mediaSession = MediaSession.Builder(context, player).build() + mediaSession = + MediaSession + .Builder(context, player) + .setCallback(TrustAllControllersCallback) + .build() activityTracker = TrackActivityPlaybackListener(api, player) { state.value.currentItemId?.let { itemId -> @@ -327,6 +331,7 @@ class MusicService imageUrlService.getItemImageUrl( itemId = albumId, imageType = ImageType.PRIMARY, + tag = audio.data.albumPrimaryImageTag, ) } return MediaItem diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 9b16718d..a2fd0eb7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.ExtractorsFactory import androidx.media3.session.MediaSession import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AppPreferences @@ -32,9 +33,15 @@ import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.util.BitstreamFilter import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory +import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion +import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory +import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.android.qualifiers.ApplicationContext import io.github.peerless2012.ass.media.AssHandler import io.github.peerless2012.ass.media.factory.AssRenderersFactory @@ -57,6 +64,7 @@ class PlayerFactory constructor( @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + private val deviceProfileService: DeviceProfileService, ) { @Volatile var currentPlayer: Player? = null @@ -94,10 +102,16 @@ class PlayerFactory val preferDolbyVision = appPreferences.experimentalPreferences .get { preferDolbyVisionOverHdr10Plus } ?: false + val convertDolbyVisionProfile7 = + DolbyVisionProfile7Conversion.isWanted( + appPreferences.experimentalPreferences, + deviceProfileService.mediaCodecCapabilitiesTest, + ) Timber.v( - "extensions=%s, assPlaybackMode=%s", + "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s", extensions, prefs.overrides.assPlaybackMode, + convertDolbyVisionProfile7, ) val rendererMode = when (extensions) { @@ -108,9 +122,18 @@ class PlayerFactory } val dataSourceFactory = DefaultDataSource.Factory(context) val extractorsFactory = createExtractorsFactory() + + // Applied outermost so that a profile 7 track is advertised as 8.1 whichever + // extractor, media3's own or libass's Matroska replacement, produced it + fun ExtractorsFactory.forDolbyVision(): ExtractorsFactory = + if (convertDolbyVisionProfile7) DolbyVisionProfile7ExtractorsFactory(this) else this var renderersFactory: RenderersFactory = - WholphinRenderersFactory(context, decodeAv1, preferDolbyVision) - .setEnableDecoderFallback(true) + WholphinRenderersFactory( + context, + decodeAv1, + preferDolbyVision, + convertDolbyVisionProfile7, + ).setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode) val mediaSourceFactory = @@ -126,15 +149,16 @@ class PlayerFactory renderersFactory = AssRenderersFactory(assHandler, renderersFactory) DefaultMediaSourceFactory( dataSourceFactory, - extractorsFactory.withAssMkvSupport( - assSubtitleParserFactory, - assHandler, - ), + extractorsFactory + .withAssMkvSupport( + assSubtitleParserFactory, + assHandler, + ).forDolbyVision(), ).setSubtitleParserFactory(assSubtitleParserFactory) } else { DefaultMediaSourceFactory( dataSourceFactory, - extractorsFactory, + extractorsFactory.forDolbyVision(), ) } val disableAudioOffload = @@ -160,8 +184,9 @@ class PlayerFactory AudioAttributes .Builder() .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) .build(), - false, + true, ) } } @@ -246,9 +271,29 @@ class PlayerFactory fun createMediaSession(player: Player) = MediaSession .Builder(context, player) + .setCallback(TrustAllControllersCallback) .build() } +/** + * Since media3 1.11, untrusted controllers (e.g. Google Home's Cast bridge, `com.google.android.apps.mediashell`) + * only get read-only player commands by default, so play/pause from them is silently dropped. + * Restore the pre-1.11 behavior of granting all player commands to every controller. + */ +@OptIn(UnstableApi::class) +object TrustAllControllersCallback : MediaSession.Callback { + override fun onConnectAsync( + session: MediaSession, + controller: MediaSession.ControllerInfo, + ): ListenableFuture = + Futures.immediateFuture( + MediaSession.ConnectionResult + .AcceptedResultBuilder(session, controller) + .setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS) + .build(), + ) +} + val Player.isReleased: Boolean get() { return when (this) { @@ -268,6 +313,7 @@ class WholphinRenderersFactory( context: Context, private val av1Enabled: Boolean, private val preferDolbyVisionOverHdr10Plus: Boolean = false, + private val convertDolbyVisionProfile7: Boolean = false, ) : DefaultRenderersFactory(context) { @OptIn(ExperimentalApi::class) override fun buildVideoRenderers( @@ -280,12 +326,16 @@ class WholphinRenderersFactory( allowedVideoJoiningTimeMs: Long, out: ArrayList, ) { + // The conversion goes first: it may shorten the access unit, and the masking then only + // has to walk what is left of it + val bitstreamFilters = + buildList { + if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter()) + if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter()) + } val videoCodecAdapterFactory = - if (preferDolbyVisionOverHdr10Plus) { - BitstreamFilteringCodecAdapterFactory( - codecAdapterFactory, - listOf(Hdr10PlusMaskingFilter()), - ) + if (bitstreamFilters.isNotEmpty()) { + BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters) } else { codecAdapterFactory } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 90d9526b..0d0eb0b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -9,20 +9,24 @@ import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.SeerrPluginLogin +import com.github.damontecres.wholphin.data.model.SeerrPluginSettings import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.ImageType +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -39,26 +43,25 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _connection = - MutableStateFlow(SeerrConnectionStatus.NotConfigured) - val connection: StateFlow = _connection + private val _connection = MutableStateFlow(SeerrState()) + val connection: StateFlow = _connection val current: Flow = - _connection.map { (it as? SeerrConnectionStatus.Success)?.current } + _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current } val currentServer: Flow = - connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server } val currentUser: Flow = - connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user } val currentUserId: Flow = current.map { it?.config?.id } /** * Whether Seerr integration is currently active of not */ val active: Flow = - connection.map { it is SeerrConnectionStatus.Success && seerrApi.active } + connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active } fun clear() { - _connection.update { SeerrConnectionStatus.NotConfigured } + _connection.update { SeerrState() } seerrApi.update("", null) } @@ -67,7 +70,7 @@ class SeerrServerRepository user: SeerrUser, exception: Exception, ) { - _connection.update { SeerrConnectionStatus.Error(server, user, exception) } + _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) } seerrApi.update("", null) } @@ -78,8 +81,9 @@ class SeerrServerRepository ) { val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() _connection.update { - SeerrConnectionStatus.Success( - CurrentSeerr(server, user, userConfig, publicSettings), + it.copy( + connection = + SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)), ) } } @@ -169,7 +173,7 @@ class SeerrServerRepository suspend fun removeServerForCurrentUser(): Boolean { val user = - when (val conn = connection.first()) { + when (val conn = connection.value.connection) { SeerrConnectionStatus.NotConfigured -> return false is SeerrConnectionStatus.Error -> conn.user is SeerrConnectionStatus.Success -> conn.current.user @@ -178,6 +182,81 @@ class SeerrServerRepository clear() return rows > 0 } + + /** + * Given the [SeerrPluginSettings], try to connect to the Seerr server + * + * @return true if successfully updated the SeerrApi and logged in + */ + suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean { + val currentUser = serverRepository.currentUser ?: return false + if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) { + val server = + SeerrServer( + id = -1, + url = settings.serverUrl, + ) + val user = + SeerrUser( + jellyfinUserRowId = currentUser.rowId, + serverId = server.id, + authMethod = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY + is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL + }, + username = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> null + is SeerrPluginLogin.Local -> settings.login.local.username + }, + password = null, + credential = null, + ) + try { + val userConfig = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> { + Timber.v("maybeAddPluginServer, using API key") + seerrApi.update(settings.serverUrl, settings.login.apiKey) + seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null) + } + + is SeerrPluginLogin.Local -> { + Timber.v("maybeAddPluginServer, using local user") + seerrApi.update(settings.serverUrl, null) + seerrLogin( + seerrApi.api, + SeerrAuthMethod.LOCAL, + settings.login.local.username, + settings.login.local.password, + ) + } + + SeerrPluginLogin.None -> { + return false + } + } + // Success + _connection.update { it.copy(pluginProvided = true) } + set(server, user, userConfig) + return true + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + clear() + Timber.w(ex, "Error adding plugin provided seerr") + _connection.update { + it.copy( + pluginProvided = true, + connection = SeerrConnectionStatus.Error(server, user, ex), + ) + } + return false + } + } + return false + } } /** @@ -185,6 +264,11 @@ class SeerrServerRepository */ typealias SeerrUserConfig = User +data class SeerrState( + val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured, + val pluginProvided: Boolean = false, +) + sealed interface SeerrConnectionStatus { data object NotConfigured : SeerrConnectionStatus @@ -205,11 +289,15 @@ data class CurrentSeerr( val config: SeerrUserConfig, val serverConfig: PublicSettings, ) { + val requestMovieEnabled: Boolean + get() = config.hasPermission(SeerrPermission.REQUEST_MOVIE) val request4kMovieEnabled: Boolean get() = (serverConfig.movie4kEnabled ?: false) && config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE) + val requestTvEnabled: Boolean + get() = config.hasPermission(SeerrPermission.REQUEST_TV) val request4kTvEnabled: Boolean get() = (serverConfig.series4kEnabled ?: false) && diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt new file mode 100644 index 00000000..8b51ad63 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt @@ -0,0 +1,96 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.data.model.SeerrPluginSettings +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.IOException +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Service for interacting with the companion plugin + */ +@Singleton +class ServerPluginApi + @Inject + constructor( + @param:AuthOkHttpClient private val okHttpClient: OkHttpClient, + private val api: ApiClient, + ) { + private fun createUrl(path: String): String? = + api.baseUrl?.let { if (it.endsWith("/")) "${it}wholphin/$path" else "$it/wholphin/$path" } + + private val json get() = HomeSettingsService.jsonParser + + companion object { + private const val HOME_CONFIG_PATH = "homesettings" + private const val SEERR_CONFIG_PATH = "seerrsettings" + } + + /** + * Check if the plugin is installed on the server + */ + suspend fun checkInstalled(): Boolean = + withContext(WholphinDispatchers.IO) { + val url = createUrl("public") ?: return@withContext false + val request = + Request + .Builder() + .url(url) + .get() + .build() + try { + return@withContext okHttpClient + .newCall(request) + .execute() + .use { it.isSuccessful } + } catch (ex: IOException) { + Timber.w("Could not connect to server plugin: %s", ex.message) + return@withContext false + } + } + + private suspend inline fun executeRequest(path: String): T? { + val url = createUrl(path) ?: return null + val request = Request(url.toHttpUrl()) + return executeRequest(request) + } + + @OptIn(ExperimentalSerializationApi::class) + private suspend inline fun executeRequest(request: Request): T? = + withContext(WholphinDispatchers.IO) { + okHttpClient.newCall(request).execute().use { res -> + if (res.isSuccessful) { + json.decodeFromStream(res.body.byteStream()) + } else if (res.code == 404) { + Timber.w("Plugin 404: %s", request.url.encodedPath) + null + } else { + throw ApiClientException(res.code.toString() + " " + res.body.string()) + } + } + } + + /** + * Get the default home page settings from the plugin + * + * @return the home page settings or null if no rows are configured or the server isn't installed + */ + suspend fun fetchHomePageSettings(): HomePageSettings? = + executeRequest(HOME_CONFIG_PATH)?.takeIf { it.rows.isNotEmpty() } + + /** + * Get the [SeerrPluginSettings] as configured by teh server plugin, or null if none are configured + */ + suspend fun fetchSeerrSettings(): SeerrPluginSettings? = executeRequest(SEERR_CONFIG_PATH) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 09450b34..0b9da9f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -1,15 +1,27 @@ package com.github.damontecres.wholphin.services -import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ActivationFlag import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.RelativeMediaStream +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.TrackChoiceParentType +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.data.model.asRelative import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream @@ -30,50 +42,69 @@ class StreamChoiceService @Inject constructor( private val serverRepository: ServerRepository, - private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, ) { private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration - suspend fun updateAudio( + suspend fun saveSeriesTrackChoice( dto: BaseItemDto, - audioLang: String, - ) = update(dto) { - it.copy( - audioLanguage = audioLang, - ) - } - - suspend fun updateSubtitles( - dto: BaseItemDto, - subtitleLang: String?, - subtitlesDisabled: Boolean, - ) = update(dto) { - it.copy( - subtitleLanguage = if (subtitlesDisabled) null else subtitleLang, - subtitlesDisabled = subtitlesDisabled, - ) - } - - suspend fun update( - dto: BaseItemDto, - update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice, + type: MediaStreamType, + stream: RelativeMediaStream?, + activation: ActivationFlag = ActivationFlag.ACTIVATED, ) { - val seriesId = dto.seriesId - if (seriesId != null) { - val userId = serverRepository.currentUser!!.rowId - val currentPlc = - playbackLanguageChoiceDao.get(userId, seriesId) - ?: PlaybackLanguageChoice(userId, seriesId, dto.id) - val newPlc = update.invoke(currentPlc) - Timber.v("Saving series PLC: %s", newPlc) - playbackLanguageChoiceDao.save(newPlc) - } + val type = + when (stream?.type ?: type) { + MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO + MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE + else -> return + } + val userId = serverRepository.currentUser!!.rowId + val newStc = + buildList { + dto.parentId + ?.let { seasonId -> + SeriesTrackChoice( + userId = userId, + itemId = dto.id, + parentId = seasonId, + type = type, + parentType = TrackChoiceParentType.SEASON, + stream = stream, + activation = activation, + ) + }?.let(::add) + dto.seriesId + ?.let { seriesId -> + SeriesTrackChoice( + userId = userId, + itemId = dto.id, + parentId = seriesId, + type = type, + parentType = TrackChoiceParentType.SERIES, + stream = stream, + activation = activation, + ) + }?.let(::add) + } + Timber.d( + "Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s", + dto.id, + dto.seriesId, + dto.parentId, + newStc, + ) + seriesTrackChoiceDao.save(newStc) } - suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) = - dto.seriesId?.let { - playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it) - } + suspend fun saveDisabledSeriesTrackChoice( + dto: BaseItemDto, + type: MediaStreamType, + ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED) + + suspend fun saveOnlyForcedSeriesTrackChoice( + dto: BaseItemDto, + type: MediaStreamType, + ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED) /** * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution @@ -104,21 +135,15 @@ class StreamChoiceService */ suspend fun chooseAudioStream( source: MediaSourceInfo, - seriesId: UUID?, + item: BaseItemDto, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + stc: List?, prefs: UserPreferences, - ): MediaStream? { - val plc = - plc ?: seriesId?.let { - playbackLanguageChoiceDao.get( - serverRepository.currentUser!!.rowId, - it, - ) - } + ): StreamChoiceResult? { + val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.AUDIO } - chooseAudioStream(candidates, itemPlayback, plc, prefs) + chooseAudioStream(candidates, itemPlayback, stc, prefs) } } @@ -128,59 +153,70 @@ class StreamChoiceService fun chooseAudioStream( candidates: List, itemPlayback: ItemPlayback?, - playbackLanguageChoice: PlaybackLanguageChoice?, + stc: List, prefs: UserPreferences, - ): MediaStream? = + ): StreamChoiceResult = if (itemPlayback?.audioIndexEnabled == true) { - candidates.firstOrNull { it.index == itemPlayback.audioIndex } - } else { - val seriesLang = - playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } - // If the user has chosen a different language for the series, prefer that - val audioLanguage = - seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) - if (audioLanguage.isNotNullOrBlank()) { - val sorted = - candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) - sorted.firstOrNull { it.language == audioLanguage && it.isDefault } - ?: sorted.firstOrNull { it.language == audioLanguage } - ?: sorted.firstOrNull { it.isDefault } - ?: sorted.firstOrNull() + candidates.firstOrNull { it.index == itemPlayback.audioIndex } with + StreamChoiceReason.Item( + itemPlayback, + ) + } else if (stc.isNotEmpty()) { + val first = stc.first() + val result = scoreStreams(candidates, first) + if (result == null) { + null with StreamChoiceReason.from(first) + } else if (result.isEmpty() && stc.size > 1) { + // SeriesTrackChoice did not apply to any streams, but there are more options + chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs) + } else if (result.isEmpty()) { + // SeriesTrackChoice did not apply to any streams, so use regular selection logic + Timber.v("No audio STC applied") + chooseAudioStream(candidates, itemPlayback, emptyList(), prefs) } else { - candidates.firstOrNull { it.isDefault } - ?: candidates.firstOrNull() + // Otherwise, use the best scored stream + Timber.v("Using audio STC from %s", first.parentId) + result.first().stream with StreamChoiceReason.from(first, result) } + } else { + val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) + val stream = + if (audioLanguage.isNotNullOrBlank()) { + val sorted = + candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + sorted.firstOrNull { it.language == audioLanguage && it.isDefault } + ?: sorted.firstOrNull { it.language == audioLanguage } + ?: sorted.firstOrNull { it.isDefault } + ?: sorted.firstOrNull() + } else { + candidates.firstOrNull { it.isDefault } + ?: candidates.firstOrNull() + } + stream with StreamChoiceReason.UserPreferences } /** * Returns the subtitle stream that should play */ suspend fun chooseSubtitleStream( + item: BaseItemDto, source: MediaSourceInfo, audioStream: MediaStream?, - seriesId: UUID?, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + stc: List?, prefs: UserPreferences, - ): MediaStream? { - val plc = - plc ?: seriesId?.let { - playbackLanguageChoiceDao.get( - serverRepository.currentUser!!.rowId, - it, - ) - } - return source.mediaStreams?.letNotEmpty { streams -> + ): StreamChoiceResult? = + source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } + val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) chooseSubtitleStream( audioStream?.language, candidates, itemPlayback, - plc, + stc, prefs, ) } - } /** * Resolves ONLY_FORCED to an actual subtitle track index. @@ -189,7 +225,7 @@ class StreamChoiceService suspend fun resolveSubtitleIndex( source: MediaSourceInfo, audioStreamIndex: Int?, - seriesId: UUID?, + item: BaseItemDto, subtitleIndex: Int, prefs: UserPreferences, ): Int? = @@ -203,17 +239,17 @@ class StreamChoiceService val itemPlayback = ItemPlayback( userId = serverRepository.currentUser!!.rowId, - itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution + itemId = item.id, subtitleIndex = TrackIndex.ONLY_FORCED, ) chooseSubtitleStream( + item = item, source = source, audioStream = audioStream, - seriesId = seriesId, itemPlayback = itemPlayback, - plc = null, + stc = null, prefs = prefs, - )?.index + )?.stream?.index } /** @@ -223,47 +259,81 @@ class StreamChoiceService audioStreamLang: String?, candidates: List, itemPlayback: ItemPlayback?, - playbackLanguageChoice: PlaybackLanguageChoice?, + stc: List, prefs: UserPreferences, - ): MediaStream? { - if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { - return null - } - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) - if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { - // Client-side manual override: User selected "Only Forced" in player menu - return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) - } else if (itemPlayback?.subtitleIndexEnabled == true) { - return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } + ): StreamChoiceResult { + if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) { + if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) { + return null with StreamChoiceReason.Item(itemPlayback) + } else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) + // Client-side manual override: User selected "Only Forced" in player menu + return findForcedTrack( + candidates, + subtitleLanguage, + audioStreamLang, + ) with StreamChoiceReason.Item(itemPlayback) + } else { + return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with + StreamChoiceReason.Item(itemPlayback) + } + } else if (stc.isNotEmpty()) { + val first = stc.first() + return when (first.activation) { + ActivationFlag.DISABLED -> { + null with StreamChoiceReason.from(first) + } + + ActivationFlag.ONLY_FORCED -> { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) + val stream = + findForcedTrack( + candidates, + subtitleLanguage, + audioStreamLang, + ) + if (stream != null) { + stream with StreamChoiceReason.from(first) + } else { + handleSubtitleStreamChoiceResult( + result = emptyList(), + audioStreamLang = audioStreamLang, + candidates = candidates, + itemPlayback = itemPlayback, + stc = stc, + prefs = prefs, + ) + } + } + + ActivationFlag.ACTIVATED -> { + val result = + scoreStreams(candidates, first) + ?: throw IllegalStateException("scoreStreams should not return null for activated") + handleSubtitleStreamChoiceResult( + result = result, + audioStreamLang = audioStreamLang, + candidates = candidates, + itemPlayback = itemPlayback, + stc = stc, + prefs = prefs, + ) + } + } } else { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) val subtitleMode = - when { - playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> { - // User has chosen a series level subtitle language, so override their normal - // subtitle mode to display that language - SubtitlePlaybackMode.ALWAYS - } - - playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> { - // Series level settings disables subtitles - SubtitlePlaybackMode.NONE - } - - else -> { - // Fallback to the user's preference - when (prefs.userPreferences?.subtitleMode) { - SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode - SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT - SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART - SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED - SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS - SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE - null -> SubtitlePlaybackMode.DEFAULT - } - } + when (prefs.userPreferences?.subtitleMode) { + SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode + SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT + SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART + SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED + SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS + SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE + null -> SubtitlePlaybackMode.DEFAULT } ?: SubtitlePlaybackMode.DEFAULT val candidates = candidates @@ -277,73 +347,104 @@ class StreamChoiceService }.thenByDescending { it.isForced && it.language.isUnknown } .thenByDescending { it.isForced }, ) - return when (subtitleMode) { - SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { - // Prefer non-forced first - !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) - } ?: candidates.firstOrNull { - it.language.equalsLangOrUnknown(subtitleLanguage) - } - } else { - candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() - } - } - - SubtitlePlaybackMode.ONLY_FORCED -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } - ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } - } else { - candidates.firstOrNull { it.isForced } - } - } - - SubtitlePlaybackMode.SMART -> { - if (subtitleLanguage.isNotNullOrBlank()) { - val audioLanguage = userConfig?.audioLanguagePreference - if ( - // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different - (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) || - // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio - (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang) - ) { - candidates.firstOrNull { it.language == subtitleLanguage } - ?: candidates.firstOrNull { it.language.isUnknown } + val stream = + when (subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { + // Prefer non-forced first + !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) + } ?: candidates.firstOrNull { + it.language.equalsLangOrUnknown(subtitleLanguage) + } } else { - // Otherwise, show forced subtitles in preferred lang - candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } - ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() } - } else { - candidates.firstOrNull { it.isDefault } + } + + SubtitlePlaybackMode.ONLY_FORCED -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } + ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } + } else { + candidates.firstOrNull { it.isForced } + } + } + + SubtitlePlaybackMode.SMART -> { + if (subtitleLanguage.isNotNullOrBlank()) { + val audioLanguage = userConfig?.audioLanguagePreference + if ( + // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different + (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) || + // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio + (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang) + ) { + candidates.firstOrNull { it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.language.isUnknown } + } else { + // Otherwise, show forced subtitles in preferred lang + candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + } + } else { + candidates.firstOrNull { it.isDefault } + } + } + + SubtitlePlaybackMode.DEFAULT -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } + ?: candidates.firstOrNull { it.isDefault || it.isForced } + } else { + candidates.firstOrNull { it.isDefault || it.isForced } + } + } + + SubtitlePlaybackMode.NONE -> { + null } } - - SubtitlePlaybackMode.DEFAULT -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } - ?: candidates.firstOrNull { it.isDefault || it.isForced } - } else { - candidates.firstOrNull { it.isDefault || it.isForced } - } - } - - SubtitlePlaybackMode.NONE -> { - null - } - } + return stream with StreamChoiceReason.UserPreferences } } - /** Returns true if the track is forced (via metadata flag or title patterns). */ - private fun isForcedOrSigns(track: MediaStream): Boolean { - if (track.isForced) return true - val title = track.title ?: track.displayTitle ?: return false - return title.contains("forced", ignoreCase = true) || - title.contains("signs", ignoreCase = true) || - title.contains("songs", ignoreCase = true) + /** + * Recursively calls [chooseSubtitleStream] based on [result] and [stc] + */ + private fun handleSubtitleStreamChoiceResult( + result: List, + audioStreamLang: String?, + candidates: List, + itemPlayback: ItemPlayback?, + stc: List, + prefs: UserPreferences, + ): StreamChoiceResult { + val first = stc.first() + return if (result.isEmpty() && stc.size > 1) { + // SeriesTrackChoice did not apply to any streams, but there are more options + chooseSubtitleStream( + audioStreamLang, + candidates, + itemPlayback, + stc.subList(1, stc.size), + prefs, + ) + } else if (result.isEmpty()) { + // SeriesTrackChoice did not apply to any streams, so use regular selection logic + Timber.v("No subtitle STC applied") + chooseSubtitleStream( + audioStreamLang, + candidates, + itemPlayback, + emptyList(), + prefs, + ) + } else { + // Otherwise, use the best scored stream + Timber.v("Using subtitle STC from %s", first.parentId) + result.first().stream with StreamChoiceReason.from(first, result) + } } /** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */ @@ -367,19 +468,92 @@ class StreamChoiceService // 3. Unknown language forced track return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) } } + + suspend fun getSeriesTrackChoices( + seriesId: UUID?, + seasonId: UUID?, + type: SeriesTrackChoiceType, + ): List { + val userId = serverRepository.currentUser?.rowId ?: return emptyList() + return when { + seriesId != null && seasonId != null -> { + seriesTrackChoiceDao.get( + userId = userId, + seasonId = seasonId, + seriesId = seriesId, + type = type, + ) + } + + seriesId != null -> { + seriesTrackChoiceDao.getBySeriesId( + userId = userId, + seriesId = seriesId, + type = type, + ) + } + + seasonId != null -> { + seriesTrackChoiceDao.getBySeasonId( + userId = userId, + seasonId = seasonId, + type = type, + ) + } + + else -> { + emptyList() + } + } + } + + suspend fun getSeriesTrackChoices( + item: BaseItemDto, + type: SeriesTrackChoiceType, + ): List = getSeriesTrackChoices(item.seriesId, item.parentId, type) + + suspend fun getSeriesTrackChoices( + item: BaseItemDto, + type: MediaStreamType, + ): List = + getSeriesTrackChoices( + seriesId = item.seriesId, + seasonId = item.parentId, + type = + when (type) { + MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO + MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE + else -> return emptyList() + }, + ) } private val String?.isUnknown: Boolean get() = - this == null || + this.isNullOrBlank() || this.equals("unknown", true) || this.equals("und", true) || this.equals("undetermined", true) || this.equals("mul", true) || this.equals("zxx", true) +private val String?.isNotUnknown: Boolean + get() = !this.isUnknown + private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown +private fun String?.equalsLangExact(lang: String?): Boolean = this.isNotUnknown && lang.isNotUnknown && equals(lang, ignoreCase = true) + +/** Returns true if the track is forced (via metadata flag or title patterns). */ +fun isForcedOrSigns(track: MediaStream): Boolean = track.isForced || isSigns(track) + +fun isSigns(track: MediaStream): Boolean { + val title = track.title ?: track.displayTitle ?: return false + return title.contains("forced", ignoreCase = true) || + title.contains("signs", ignoreCase = true) || + title.contains("songs", ignoreCase = true) +} + /** * Based on the user's preferences, get their preferred language for audio or subtitles * @@ -412,3 +586,153 @@ fun getPreferredLanguage( else -> pref }?.takeIf { it.isNotNullOrBlank() } } + +/** + * Scores streams based the user's explicit [SeriesTrackChoice] + * + * @return sorted list of preferred streams or null if stream should be disabled + */ +fun scoreStreams( + streams: List, + choice: SeriesTrackChoice, +): List? { + if (choice.activation == ActivationFlag.DISABLED) { + return null + } + val streams = + streams.filter { + if ( + (choice.activation == ActivationFlag.ONLY_FORCED || choice.has(TrackFlag.FORCED)) && + !isForcedOrSigns(it) + ) { + // Filter out non-forced tracks if the user only wants forced + return@filter false + } + if (choice.language.isNotUnknown && + !choice.language.equalsLangExact(it.language) + ) { + // Filter out languages that do not match + return@filter false + } + // Filter by type + choice.type.mediaStreamType == it.type + } + val scored = + streams + .asRelative() + .map { s -> + var score = 0 + + // Prefer exact language match over unknown + if (choice.language.equalsLangExact(s.language)) score += 10_000 + + if (s.isForced && choice.has(TrackFlag.FORCED)) { + score += 1_000 + } + if (choice.has(TrackFlag.SIGNS) && isSigns(s.stream)) { + score += 500 + } + if (s.isDefault && choice.has(TrackFlag.DEFAULT)) { + score += 500 + } + + if (s.isHearingImpaired && choice.has(TrackFlag.SDH)) { + score += 500 + } else if (choice.has(TrackFlag.SDH) && s.isForced) { + // If user wants SDH, prefer non-forced "regular" over forced + score -= 10 + } + + if (s.isExternal && choice.has(TrackFlag.EXTERNAL)) { + score += 100 + } + + if (s.relativeIndex == choice.relativeTrackIndex) { + score += 100 + } + if (s.stream.title == choice.title) { + score += 10 + } + + if (choice.audioChannels != null && s.stream.channels == choice.audioChannels) { + score += 100 + } else if (choice.audioChannels != null && s.stream.channels.gt(choice.audioChannels)) { + score += 50 + } + + if (s.isDefault) { + // All else being equal, prefer the default track + score += 1 + } + ScoredMediaStream(score, s.stream) + }.sortedByDescending { it.score } + return scored +} + +data class StreamChoiceResult( + val stream: MediaStream?, + val reason: StreamChoiceReason, +) + +data class ScoredMediaStream( + val score: Int, + val stream: MediaStream, +) + +private infix fun MediaStream?.with(reason: StreamChoiceReason) = StreamChoiceResult(this, reason) + +/** + * Why a particular [MediaStream] was chosen + */ +sealed interface StreamChoiceReason { + val description: StringProvider + val scores: List get() = emptyList() + + data object Unknown : StreamChoiceReason { + override val description: StringProvider = ResStringProvider(R.string.unknown) + } + + data object UserPreferences : StreamChoiceReason { + override val description: StringProvider = + ResStringProvider(R.string.profile_specific_settings) + } + + data class Series( + val stc: SeriesTrackChoice, + override val scores: List, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER) + } + + data class Season( + val stc: SeriesTrackChoice, + override val scores: List, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, SEASON_PROVIDER) + } + + data class Item( + val itemPlayback: ItemPlayback, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, USER_PROVIDER) + } + + companion object { + fun from( + stc: SeriesTrackChoice, + scores: List = emptyList(), + ) = when (stc.parentType) { + TrackChoiceParentType.SERIES -> Series(stc, scores) + TrackChoiceParentType.SEASON -> Season(stc, scores) + TrackChoiceParentType.DEPRECATED -> Unknown + TrackChoiceParentType.UNKNOWN -> Unknown + } + + private val USER_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.user) } + private val SERIES_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.series) } + private val SEASON_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.tv_season) } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index aa64058d..e0dd472f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences @@ -63,12 +64,8 @@ class SuggestionsWorker } if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { - var currentUser = serverRepository.current.value - if (currentUser == null) { - serverRepository.restoreSession(serverId, userId) - currentUser = serverRepository.current.value - } - if (currentUser == null) { + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { Timber.w("No user found during run") return Result.failure() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt index 06115982..b208d663 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException /** * Listens for JF user switching in the app to also switch other settings like Seerr user/server @@ -36,6 +37,7 @@ class UserSwitchListener private val seerrServerDao: SeerrServerDao, private val seerrApi: SeerrApi, private val homeSettingsService: HomeSettingsService, + private val serverPluginApi: ServerPluginApi, ) { init { context as AppCompatActivity @@ -65,49 +67,78 @@ class UserSwitchListener AppCompatDelegate.setApplicationLocales(localeList) } - // Check for home settings + // Check if plugin is installed, then for home settings & seerr settings launchIO { - homeSettingsService.loadCurrentSettings(user.id) - } - if (BuildConfig.DISCOVER_ENABLED) { - // Check for seerr server + val serverPluginInstalled = + try { + serverPluginApi.checkInstalled() + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error checking for server plugin") + false + } + Timber.i("Server plugin installed: %s", serverPluginInstalled) + serverRepository.serverPluginInstalled.value = serverPluginInstalled + launchIO { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - seerrLogin( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set( - server, - seerrUser, - userConfig, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server, seerrUser, ex) + // Check for home settings + homeSettingsService.loadCurrentSettings(user) + } + + if (BuildConfig.DISCOVER_ENABLED) { + // Check for seerr server + launchIO { + if (serverPluginInstalled) { + serverPluginApi.fetchSeerrSettings()?.let { + Timber.d("Trying seerr auto login") + val result = seerrServerRepository.usePluginProvidedServer(it) + if (result) { + // Successfully used server plugin provided seerr + Timber.i("Seerr auto login succeeded") + return@launchIO } } } + Timber.v("Checking for existing configured seerr server") + // Either no server plugin seerr or there was an issue with it + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + seerrLogin( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set( + server, + seerrUser, + userConfig, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server, seerrUser, ex) + } + } + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 06607cf9..3a114017 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.MostRecentServerProvider +import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory @@ -188,6 +190,12 @@ object AppModule { fun seerrApi( @StandardOkHttpClient okHttpClient: OkHttpClient, ) = SeerrApi(okHttpClient) + + @Provides + @Singleton + fun mostRecentServerProvider( + @ApplicationContext context: Context, + ): MostRecentServerProvider = MostRecentServerSharedPreferences(context) } @Module diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 29688664..78270a9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer @@ -79,6 +80,10 @@ object DatabaseModule { @Singleton fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides + @Singleton + fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index d3dbf4c7..a4d73caa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -19,6 +19,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference @@ -70,12 +71,8 @@ class TvProviderWorker if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { // Not active - var currentUser = serverRepository.current.value - if (currentUser == null) { - serverRepository.restoreSession(serverId, userId) - currentUser = serverRepository.current.value - } - if (currentUser == null) { + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { Timber.w("No user found during run") return Result.failure() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index f774a0da..a284ef0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -8,6 +8,9 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.preferences.AppPreference.Companion.MEGA_BIT +import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaSegmentType @@ -205,6 +208,13 @@ fun formatBytes( fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000) +fun formatMaxBitrate(bitrate: Long) = + if (bitrate < MEGA_BIT) { + "${bitrate / 1024} kbps" + } else { + "${bitrate / MEGA_BIT} Mbps" + } + @get:StringRes val MediaSegmentType.stringRes: Int get() = @@ -300,3 +310,55 @@ fun Resources.formatDuration(duration: Duration): String = else -> getString(R.string.duration_seconds, seconds) } } + +fun Resources.serverSupportedMessage( + serverVersionSupported: ServerVersionSupported, + serverVersion: ServerVersion?, +): String? { + val errorMsg = + when (serverVersionSupported) { + ServerVersionSupported.SUPPORTED -> { + null + } + + ServerVersionSupported.NOT_SUPPORTED, + ServerVersionSupported.UNKNOWN, + -> { + getString(R.string.server_version_not_supported) + } + + ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> { + getString(R.string.server_version_not_supported_future) + } + } + return errorMsg?.let { + val msg = + if (serverVersion != null) { + "$it: $serverVersion" + } else { + it + ": " + getString(R.string.unknown) + } + msg + "\n" + + when (serverVersionSupported) { + ServerVersionSupported.SUPPORTED -> { + "" + } + + ServerVersionSupported.NOT_SUPPORTED, + ServerVersionSupported.UNKNOWN, + -> { + getString( + R.string.server_version_required, + WholphinApplication.minimumServerVersion.toString(), + ) + } + + ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> { + getString( + R.string.server_version_required, + WholphinApplication.newMinimumServerVersion.toString(), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 1d70553e..af67f12c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -44,7 +44,6 @@ import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.GridItemDetails import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.main.HomePageHeader -import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType @@ -53,19 +52,18 @@ import kotlin.time.Duration @OptIn(ExperimentalFoundationApi::class) @Composable -fun CollectionFolderGrid( +fun CollectionFolderViewScope.CollectionFolderGrid( preferences: UserPreferences, - collectionType: CollectionType?, focusedItem: BaseItem?, items: List, sortAndDirection: SortAndDirection, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, letterPosition: suspend (Char) -> Int, - viewOptions: ViewOptions, onClickPlay: (Int, BaseItem) -> Unit, initialPosition: Int, gridFocusRequester: FocusRequester, + cardContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit, modifier: Modifier = Modifier, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, ) { @@ -99,18 +97,8 @@ fun CollectionFolderGrid( modifier = Modifier.fillMaxSize(), initialPosition = initialPosition, positionCallback = positionCallback, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> - GridCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - imageContentScale = viewOptions.contentScale.scale, - imageAspectRatio = viewOptions.aspectRatio.ratio, - imageType = viewOptions.imageType, - showTitle = viewOptions.showTitles, - fillWidth = widthPx, - modifier = mod, - ) + cardContent = { details -> + cardContent.invoke(this@CollectionFolderGrid, details) }, columns = viewOptions.columns, spacing = viewOptions.spacing.dp, @@ -210,10 +198,10 @@ val CollectionType.baseItemKinds: List listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE) } - CollectionType.HOMEVIDEOS -> { - listOf(BaseItemKind.VIDEO) - listOf(BaseItemKind.PHOTO) - listOf(BaseItemKind.PHOTO_ALBUM) + CollectionType.PHOTOS, + CollectionType.HOMEVIDEOS, + -> { + listOf(BaseItemKind.VIDEO, BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) } CollectionType.MUSIC -> { @@ -236,10 +224,6 @@ val CollectionType.baseItemKinds: List listOf(BaseItemKind.PLAYLIST) } - CollectionType.PHOTOS -> { - listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) - } - CollectionType.LIVETV -> { listOf( BaseItemKind.TV_CHANNEL, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt index 967ddb07..57ff8957 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator import com.github.damontecres.wholphin.ui.cards.WatchedIcon import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.AlphabetButtons +import com.github.damontecres.wholphin.ui.detail.GridItemDetails import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.ifElse @@ -75,7 +77,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.extensions.ticks @Composable -fun CollectionFolderList( +fun CollectionFolderViewScope.CollectionFolderList( preferences: UserPreferences, collectionType: CollectionType?, focusedItem: BaseItem?, @@ -84,10 +86,10 @@ fun CollectionFolderList( onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, letterPosition: suspend (Char) -> Int, - viewOptions: ViewOptions, onClickPlay: (Int, BaseItem) -> Unit, initialPosition: Int, gridFocusRequester: FocusRequester, + itemContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit, modifier: Modifier = Modifier, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, ) { @@ -153,10 +155,8 @@ fun CollectionFolderList( ).padding(8.dp), ) } - CollectionFolderContent( + this@CollectionFolderList.CollectionFolderListContent( items = items, - viewOptions = viewOptions, - collectionType = collectionType, listState = listState, position = position, positionFocusRequester = positionFocusRequester, @@ -167,6 +167,7 @@ fun CollectionFolderList( }, onClickItem = onClickItem, onLongClickItem = onLongClickItem, + itemContent = itemContent, modifier = Modifier .weight(1f) @@ -266,44 +267,43 @@ fun CollectionFolderListDetails( } @Composable -fun CollectionFolderContent( +fun CollectionFolderViewScope.CollectionFolderListContent( items: List, - viewOptions: ViewOptions, - collectionType: CollectionType?, listState: LazyListState, position: Int, positionFocusRequester: FocusRequester, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, onPositionChange: (Int) -> Unit, + itemContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit, modifier: Modifier = Modifier, ) { + val currentPosition by rememberUpdatedState(position) LazyColumn( state = listState, verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp), modifier = modifier.focusRestorer(positionFocusRequester), ) { itemsIndexed(items) { index, item -> - val onClick: () -> Unit = - remember(index, item) { - { item?.let { onClickItem.invoke(index, item) } } + val details = + remember(item, index, onClickItem, onLongClickItem, onPositionChange) { + GridItemDetails( + item = item, + index = index, + onClick = { item?.let { onClickItem.invoke(index, item) } }, + onLongClick = { item?.let { onLongClickItem.invoke(index, item) } }, + widthPx = -1, + mod = + Modifier + .onFocusChanged { + if (it.isFocused) onPositionChange.invoke(index) + }.ifElse( + index == currentPosition, + Modifier.focusRequester(positionFocusRequester), + ), + ) } - val onLongClick: () -> Unit = - remember(index, item) { - { item?.let { onLongClickItem.invoke(index, item) } } - } - CollectionFolderListItem( - item = item, - dense = viewOptions.type == ViewOptionsType.DENSE_LIST, - collectionType = collectionType, - onClick = onClick, - onLongClick = onLongClick, - modifier = - Modifier - .onFocusChanged { - if (it.isFocused) onPositionChange.invoke(index) - }.ifElse(index == position, Modifier.focusRequester(positionFocusRequester)), - ) + itemContent.invoke(this@CollectionFolderListContent, details) } } } @@ -522,9 +522,12 @@ fun ListItemAlbum( } val supportingContent = remember(item, dense) { - if (!dense && item?.subtitle != null) { + // Album artist is already shown as the overline + val subtitle = + if (item?.type == BaseItemKind.MUSIC_ALBUM) item.data.productionYear?.toString() else item?.subtitle + if (!dense && subtitle != null) { @Composable { - Text(item.subtitle ?: "") + Text(subtitle) } } else { null diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index 52918e57..8445dfa9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -54,20 +54,25 @@ import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.GridItemDetails import com.github.damontecres.wholphin.ui.detail.music.addToQueue import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.goToRandom import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler @@ -89,12 +94,14 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest @@ -146,6 +153,9 @@ class CollectionFolderViewModel private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions)) val state: StateFlow = _state + private val artistSource: ArtistSource + get() = state.value.viewOptions.artistSource + var position: Int get() = savedStateHandle.get("position") ?: 0 set(value) { @@ -168,15 +178,25 @@ class CollectionFolderViewModel val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId libraryDisplayInfoDao.getItem(user, id) } + val ancestorDisplayInfo = + if (libraryDisplayInfo?.viewOptions == null && item != null) { + getAncestorDisplayInfo(item) + } else { + null + } _state.update { it.copy( - viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions, + viewOptions = + libraryDisplayInfo?.viewOptions + ?: ancestorDisplayInfo?.viewOptions + ?: defaultViewOptions, ) } val sortAndDirection = if (collectionFilter.useSavedLibraryDisplayInfo) { libraryDisplayInfo?.sortAndDirection + ?: ancestorDisplayInfo?.sortAndDirection } else { null } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT @@ -232,6 +252,25 @@ class CollectionFolderViewModel } } + /** + * For a sub-folder, returns the saved display info of the closest ancestor that has one + */ + private suspend fun getAncestorDisplayInfo(item: BaseItem): LibraryDisplayInfo? { + if (item.type != BaseItemKind.FOLDER || collectionFilter.libraryDisplayInfoIdOverride != null) { + return null + } + val user = serverRepository.currentUser ?: return null + return try { + api.libraryApi + .getAncestors(item.id, user.id) + .content + .firstNotNullOfOrNull { ancestor -> libraryDisplayInfoDao.getItem(user, ancestor.id) } + } catch (ex: Exception) { + Timber.w(ex, "Error getting ancestors for %s", item.id) + null + } + } + private fun saveLibraryDisplayInfo( newFilter: GetItemsFilter = state.value.filter, newSort: SortAndDirection = state.value.sortAndDirection, @@ -257,7 +296,17 @@ class CollectionFolderViewModel override fun saveViewOptions(viewOptions: ViewOptions) { position = 0 + val artistSourceChanged = viewOptions.artistSource != artistSource _state.update { it.copy(viewOptions = viewOptions) } + if (artistSourceChanged && state.value.filter.override == GetItemsFilterOverride.ARTIST) { + loadResults( + true, + state.value.sortAndDirection, + recursive, + state.value.filter, + useSeriesForPrimary, + ) + } viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { saveLibraryDisplayInfo(viewOptions = viewOptions) if (!viewOptions.showBackdrop) { @@ -269,11 +318,19 @@ class CollectionFolderViewModel override fun onClickRandom() { viewModelScope.launchIO { try { - val random = - (state.value.items.successValue as? BlockingList)?.randomBlocking() - Timber.d("Got random item: %s", random?.id) - random?.destination()?.let { - navigateTo(random.destination()) + val state = state.value + val list = state.items.successValue as? BlockingList + if (list != null) { + goToRandom( + navigateTo = ::navigateTo, + parentId = itemId, + filter = state.filter, + sortAndDirection = state.sortAndDirection, + recursive = recursive, + list = list, + ) + } else { + Timber.w("state.value.items is not successful: %s", state.items) } } catch (ex: CancellationException) { throw ex @@ -399,13 +456,27 @@ class CollectionFolderViewModel } GetItemsFilterOverride.ARTIST -> { - ApiRequestPager( - api, - createGetArtistsRequest(filter), - GetArtistsHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) + when (artistSource) { + ArtistSource.ARTIST -> { + ApiRequestPager( + api, + createGetArtistsRequest(filter), + GetArtistsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + } + + ArtistSource.ALBUM_ARTIST -> { + ApiRequestPager( + api, + createGetAlbumArtistsRequest(filter), + GetAlbumArtistsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + } + } } } @@ -424,6 +495,17 @@ class CollectionFolderViewModel ) } + private fun createGetAlbumArtistsRequest(filter: GetItemsFilter): GetAlbumArtistsRequest { + val item = state.value.item.successValue + return filter.applyTo( + GetAlbumArtistsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + fields = SlimItemFields, + ), + ) + } + private fun createGetItemsRequest( sortAndDirection: SortAndDirection, recursive: Boolean, @@ -495,18 +577,37 @@ class CollectionFolderViewModel val filter = state.value.filter when (filter.override) { GetItemsFilterOverride.ARTIST -> { - GetArtistsHandler.countMatching( - api = api, - request = - createGetArtistsRequest(filter).copy( - enableImageTypes = null, - fields = null, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - enableUserData = false, - ), - ) + when (artistSource) { + ArtistSource.ARTIST -> { + GetArtistsHandler.countMatching( + api = api, + request = + createGetArtistsRequest(filter).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), + ) + } + + ArtistSource.ALBUM_ARTIST -> { + GetAlbumArtistsHandler.countMatching( + api = api, + request = + createGetAlbumArtistsRequest(filter).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), + ) + } + } } // GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be @@ -813,6 +914,33 @@ fun CollectionFolderViewContent( positionCallback: ((columns: Int, position: Int) -> Unit)? = null, filterOptions: List> = DefaultFilterOptions, focusRequesterOnEmpty: FocusRequester? = null, + gridCardContent: @Composable CollectionFolderViewScope.( + GridItemDetails, + ) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = viewOptions.contentScale.scale, + imageAspectRatio = viewOptions.aspectRatio.ratio, + imageType = viewOptions.imageType, + showTitle = viewOptions.showTitles, + fillWidth = widthPx, + modifier = mod, + ) + }, + listItemContent: @Composable CollectionFolderViewScope.( + GridItemDetails, + ) -> Unit = { (item, index, onClick, onLongClick, _, mod) -> + CollectionFolderListItem( + item = item, + dense = viewOptions.type == ViewOptionsType.DENSE_LIST, + collectionType = collectionType, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + ) + }, ) { var position by rememberInt(savedPosition) @@ -975,11 +1103,22 @@ fun CollectionFolderViewContent( focusRequester?.tryRequestFocus() clickedRandom = false } + val scope = + object : CollectionFolderViewScope { + override val viewOptions: ViewOptions + get() = state.viewOptions + override val collectionType: CollectionType? + get() = item?.data?.collectionType + override val sortAndDirection: SortAndDirection + get() = state.sortAndDirection + override val collectionSize: Int + get() = pager.data.size + } + Box(Modifier.fillMaxSize()) { if (state.viewOptions.type == ViewOptionsType.GRID) { - CollectionFolderGrid( + scope.CollectionFolderGrid( preferences = preferences, - collectionType = item?.data?.collectionType, initialPosition = savedPosition, items = pager.data, sortAndDirection = state.sortAndDirection, @@ -993,12 +1132,12 @@ fun CollectionFolderViewContent( positionCallback?.invoke(columns, pos) }, letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, - viewOptions = state.viewOptions, onClickPlay = gridActions.onClickPlayRemoteButton!!, focusedItem = focusedItem, + cardContent = gridCardContent, ) } else { - CollectionFolderList( + scope.CollectionFolderList( preferences = preferences, collectionType = item?.data?.collectionType, initialPosition = savedPosition, @@ -1014,9 +1153,9 @@ fun CollectionFolderViewContent( positionCallback?.invoke(columns, pos) }, letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, - viewOptions = state.viewOptions, onClickPlay = gridActions.onClickPlayRemoteButton!!, focusedItem = focusedItem, + itemContent = listItemContent, ) } androidx.compose.animation.AnimatedVisibility( @@ -1047,6 +1186,7 @@ fun CollectionFolderViewContent( ViewOptionsDialog( viewOptions = state.viewOptions, defaultViewOptions = defaultViewOptions, + showArtistSource = state.filter.override == GetItemsFilterOverride.ARTIST, onDismissRequest = { showViewOptions = false viewActions.saveViewOptions(state.viewOptions) @@ -1055,3 +1195,13 @@ fun CollectionFolderViewContent( ) } } + +/** + * Scope with global data for [CollectionFolderView], [CollectionFolderGrid], [CollectionFolderList], and individual items + */ +interface CollectionFolderViewScope { + val viewOptions: ViewOptions + val collectionType: CollectionType? + val sortAndDirection: SortAndDirection + val collectionSize: Int +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index 10a41af3..f87010d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -209,7 +209,12 @@ fun ContextMenu( favorite = item.favorite, seriesId = item.data.seriesId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), - canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, + canClearChosenStreams = + chosenStreams.let { + it?.itemPlayback != null || + it?.audioStc?.isNotEmpty() == true || + it?.subtitleStc?.isNotEmpty() == true + }, contextMenu = contextMenu, actions = actions, onChooseVersion = { @@ -235,11 +240,11 @@ fun ContextMenu( resources = resources, streams = source.mediaStreams.orEmpty(), type = type, - currentIndex = + streamChoice = if (type == MediaStreamType.AUDIO) { - chosenStreams?.audioStream?.index + chosenStreams?.audioStream } else { - chosenStreams?.subtitleStream?.index + chosenStreams?.subtitleStream }, onClick = { trackIndex -> actions.onChooseTracks.invoke( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index d7bd9951..76b7dc77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -64,6 +64,7 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -230,28 +231,35 @@ fun DialogPopup( onDismissRequest = onDismissRequest, properties = properties, ) { + val elevatedContainerColor = + MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) DialogPopupContent( title = title, dialogItems = dialogItems, waiting = waiting, onDismissRequest = onDismissRequest, dismissOnClick = dismissOnClick, - elevation = elevation, modifier = - Modifier.onKeyEvent { event -> - val code = event.nativeKeyEvent.keyCode - if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && - code in - setOf( - KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_NUMPAD_ENTER, - ) - ) { - waiting = false - } - false - }, + Modifier + .onKeyEvent { event -> + val code = event.nativeKeyEvent.keyCode + if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && + code in + setOf( + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + ) + ) { + waiting = false + } + false + }.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp)) + .graphicsLayer { + this.clip = true + this.shape = RoundedCornerShape(28.0.dp) + }.drawBehind { drawRect(color = elevatedContainerColor) } + .padding(PaddingValues(24.dp)), ) } } @@ -265,20 +273,10 @@ fun DialogPopupContent( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, dismissOnClick: Boolean = true, - elevation: Dp = 8.dp, ) { - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp)) - .graphicsLayer { - this.clip = true - this.shape = RoundedCornerShape(28.0.dp) - }.drawBehind { drawRect(color = elevatedContainerColor) } - .padding(PaddingValues(24.dp)), + modifier = modifier, ) { Text( text = title, @@ -452,12 +450,14 @@ fun ConfirmDialog( properties: DialogProperties = DialogProperties(), elevation: Dp = 1.dp, bodyColor: Color = MaterialTheme.colorScheme.onSurface, + @StringRes cancelStringRes: Int = R.string.cancel, + @StringRes confirmStringRes: Int = R.string.confirm, ) = BasicDialog( onDismissRequest = onCancel, properties = properties, elevation = elevation, content = { - ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor) + ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes) }, ) @@ -472,6 +472,8 @@ fun ConfirmDialogContent( onConfirm: () -> Unit, modifier: Modifier = Modifier, bodyColor: Color = MaterialTheme.colorScheme.onSurface, + @StringRes cancelStringRes: Int = R.string.cancel, + @StringRes confirmStringRes: Int = R.string.confirm, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -502,11 +504,11 @@ fun ConfirmDialogContent( modifier = Modifier.fillMaxWidth(), ) { TextButton( - stringRes = R.string.cancel, + stringRes = cancelStringRes, onClick = onCancel, ) TextButton( - stringRes = R.string.confirm, + stringRes = confirmStringRes, onClick = onConfirm, ) } @@ -651,12 +653,13 @@ fun resourceFor(type: MediaStreamType): Int = fun chooseStream( resources: Resources, streams: List, - currentIndex: Int?, + streamChoice: StreamChoiceResult?, type: MediaStreamType, preferredSubtitleLanguage: String?, onClick: (Int) -> Unit, ): DialogParams { val filteredStreams = streams.filter { it.type == type } + val currentIndex = streamChoice?.stream?.index return DialogParams( fromLongClick = false, title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))), @@ -674,6 +677,16 @@ fun chooseStream( }, supportingContent = { }, + trailingContent = + if (currentIndex == null && streamChoice != null) { + { + Text( + text = streamChoice.reason.description.getString(), + ) + } + } else { + null + }, onClick = { onClick.invoke(TrackIndex.DISABLED) }, ), ) @@ -719,6 +732,16 @@ fun chooseStream( supportingContent = { if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) }, + trailingContent = + if (currentIndex == stream.index) { + { + Text( + text = streamChoice.reason.description.getString(), + ) + } + } else { + null + }, onClick = { onClick.invoke(stream.index) }, ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt index 9ac5adc0..73e9af44 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt @@ -60,7 +60,7 @@ private fun getRecommendedRows(parentId: UUID) = ), ), RecommendedRow( - title = R.string.top_unwatched, + title = R.string.top_rated, handler = GetItemsRequestHandler, request = GetItemsRequest( @@ -69,7 +69,6 @@ private fun getRecommendedRows(parentId: UUID) = includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), recursive = true, enableUserData = true, - isPlayed = false, sortBy = listOf(ItemSortBy.COMMUNITY_RATING), sortOrder = listOf(SortOrder.DESCENDING), enableTotalRecordCount = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index 9ff0e2c0..b69e1126 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -97,6 +97,7 @@ class StudioViewModel itemId = it.id, imageType = ImageType.THUMB, fillWidth = cardWidthPx, + tag = it.imageTags?.get(ImageType.THUMB), ) Studio( id = it.id, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt index c5da7565..4268f2d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt @@ -95,7 +95,11 @@ fun rememberLogoUrl(item: BaseItem?): String? { item.data.seriesId != null && item.data.parentLogoImageTag != null ) { - imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO) + imageUrlService.getItemImageUrl( + itemId = item.data.seriesId!!, + imageType = ImageType.LOGO, + tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId }, + ) } else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) { imageUrlService.getItemImageUrl(item, ImageType.LOGO) } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index 083dded6..94842ef5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -54,8 +54,8 @@ fun VideoStreamDetails( ) = VideoStreamDetails( chosenStreams?.source, chosenStreams?.videoStream, - chosenStreams?.audioStream, - chosenStreams?.subtitleStream, + chosenStreams?.audioStream?.stream, + chosenStreams?.subtitleStream?.stream, numberOfVersions, modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt index a1237acd..df923a55 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -55,6 +55,7 @@ fun ViewOptionsDialog( onDismissRequest: () -> Unit, onViewOptionsChange: (ViewOptions) -> Unit, defaultViewOptions: ViewOptions = ViewOptions(), + showArtistSource: Boolean = false, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } @@ -72,11 +73,11 @@ fun ViewOptionsDialog( window.setDimAmount(0f) } val options = - remember(viewOptions.type) { + remember(viewOptions.type, showArtistSource) { when (viewOptions.type) { - ViewOptionsType.GRID -> ViewOptions.GRID_OPTIONS - ViewOptionsType.LIST -> ViewOptions.LIST_OPTIONS - ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS + ViewOptionsType.GRID -> ViewOptions.buildGridOptions(showArtistSource) + ViewOptionsType.LIST -> ViewOptions.buildListOptions(showArtistSource) + ViewOptionsType.DENSE_LIST -> ViewOptions.buildListOptions(showArtistSource) } } Column( @@ -130,6 +131,12 @@ fun ViewOptionsDialog( } } +/** Which Jellyfin artists list backs the Music > Artists tab */ +enum class ArtistSource { + ARTIST, + ALBUM_ARTIST, +} + /** * Stores the customizable view changes from the user * @@ -146,6 +153,7 @@ data class ViewOptions( val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, val showTitles: Boolean = true, val type: ViewOptionsType = ViewOptionsType.GRID, + val extras: ViewOptionsExtras = ViewOptionsExtras.None, ) { companion object { val ViewOptionsColumns = @@ -250,12 +258,34 @@ data class ViewOptions( valueToIndex = { it.ordinal }, ) + val ViewOptionsArtistSource = + AppChoicePreference( + title = R.string.artist_source, + defaultValue = ArtistSource.ARTIST, + displayValues = R.array.artist_sources, + getter = { it.artistSource }, + setter = { viewOptions, value -> + viewOptions.copy(extras = ViewOptionsExtras.MusicLibrary(value)) + }, + indexToValue = { ArtistSource.entries[it] }, + valueToIndex = { it.ordinal }, + ) + val ViewOptionsReset = AppClickablePreference( title = R.string.reset, ) - val GRID_OPTIONS = + /** + * Get the options for a grid [ViewOptions] + */ + fun buildGridOptions(showArtistSource: Boolean) = + buildList { + if (showArtistSource) add(ViewOptionsArtistSource) + addAll(GRID_OPTIONS) + } + + private val GRID_OPTIONS = listOf( ViewOptionsTypePref, ViewOptionsImageType, @@ -269,7 +299,16 @@ data class ViewOptions( ViewOptionsReset, ) - val LIST_OPTIONS = + /** + * Get the options for a list [ViewOptions] + */ + fun buildListOptions(showArtistSource: Boolean) = + buildList { + if (showArtistSource) add(ViewOptionsArtistSource) + addAll(LIST_OPTIONS) + } + + private val LIST_OPTIONS = listOf( ViewOptionsTypePref, ViewOptionsDetailHeader, @@ -338,3 +377,22 @@ val BaseItemKind.defaultViewOptions: ViewOptions else -> ViewOptionsPoster } + +/** + * Extras [ViewOptions] info that is context or library specific + */ +@Serializable +sealed interface ViewOptionsExtras { + @Serializable + data object None : ViewOptionsExtras + + @Serializable + data class MusicLibrary( + val artistSource: ArtistSource = ArtistSource.ARTIST, + ) : ViewOptionsExtras +} + +/** + * The [ArtistSource] if the [ViewOptions.extras] is a [ViewOptionsExtras.MusicLibrary] + */ +val ViewOptions.artistSource: ArtistSource get() = (extras as? ViewOptionsExtras.MusicLibrary)?.artistSource ?: ArtistSource.ARTIST diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 7c8268b0..fb4cdbdd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -9,11 +9,16 @@ import android.media.AudioManager import android.os.Build import android.util.Log import android.view.Display +import android.view.Display.HdrCapabilities import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalGridApi +import androidx.compose.foundation.layout.Grid +import androidx.compose.foundation.layout.GridFlow +import androidx.compose.foundation.layout.GridTrackSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -32,6 +37,7 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel @@ -40,9 +46,11 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.ExceptionHandler @@ -56,9 +64,11 @@ import kotlinx.coroutines.withContext import org.acra.util.versionCodeLong import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader import java.util.Date +import java.util.Locale import javax.inject.Inject @HiltViewModel @@ -68,17 +78,18 @@ class DebugViewModel @param:ApplicationContext private val context: Context, val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, + val seriesTrackChoiceDao: SeriesTrackChoiceDao, val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, + private val deviceProfileService: DeviceProfileService, ) : ViewModel() { val state = MutableStateFlow(DebugState()) val itemPlaybacks = MutableStateFlow>(emptyList()) val logcat = MutableStateFlow>(emptyList()) - val supportedModes by lazy { + val display: Display by lazy { val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) - display.supportedModes.orEmpty() + displayManager.getDisplay(Display.DEFAULT_DISPLAY) } val av1Included by lazy { @@ -159,7 +170,7 @@ class DebugViewModel state.update { it.copy( deviceInfo = deviceInfoList, - displayModes = supportedModes.map { it.toString() }, + displayModes = display.supportedModes.map { it.toDisplayMode(display) }, ) } } @@ -169,6 +180,13 @@ class DebugViewModel itemPlaybacks.value = results } } + viewModelScope.launchIO { + serverRepository.currentUser?.rowId?.let { + val results = seriesTrackChoiceDao.getAll(it) + Timber.v("Got %s SeriesTrackChoice", results.size) + results.forEach { Timber.v("%s", it) } + } + } viewModelScope.launchIO { val logcat = getLogCatLines() this@DebugViewModel.logcat.value = logcat @@ -206,6 +224,20 @@ class DebugViewModel audioManager.registerAudioDeviceCallback(callback, null) addCloseable { audioManager.unregisterAudioDeviceCallback(callback) } } + viewModelScope.launchIO { + val hdrSupport = + buildMap { + put("HEVC HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10()) + put("HEVC HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10Plus()) + put("DoVi single layer", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVision()) + put("DoVi Profile 7 EL", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionEL()) + put("DoVi Profile 8", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionProfile8()) + put("AV1 HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10()) + put("AV1 HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10Plus()) + put("AV1 DoVi", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1DolbyVision()) + } + state.update { it.copy(hdrSupport = hdrSupport) } + } } companion object { @@ -262,8 +294,9 @@ data class LogcatLine( data class DebugState( val appInfo: List = emptyList(), val deviceInfo: List = emptyList(), - val displayModes: List = emptyList(), + val displayModes: List = emptyList(), val audioInfo: List = emptyList(), + val hdrSupport: Map = emptyMap(), ) val AudioDeviceInfo.details: String @@ -383,6 +416,7 @@ val AudioDeviceInfo.details: String "productName=$productName, address=$addressStr)" } +@OptIn(ExperimentalGridApi::class) @Composable fun DebugPage( preferences: UserPreferences, @@ -466,15 +500,54 @@ fun DebugPage( BodyText(it) } - SubSectionTitle("Display Modes") - state.displayModes.forEach { - BodyText(it) - } - SubSectionTitle("Audio Devices") state.audioInfo.forEach { BodyText(it) } + + SubSectionTitle("Display Modes") + Grid( + config = { + repeat(6) { column(GridTrackSize.Auto) } + flow = GridFlow.Row + columnGap(8.dp) + rowGap(8.dp) + }, + ) { + EmphasizedBodyText("ID") + EmphasizedBodyText("Width") + EmphasizedBodyText("Height") + EmphasizedBodyText("FPS") + EmphasizedBodyText("HDR") + EmphasizedBodyText("Alt FPS") + + state.displayModes.forEachIndexed { index, mode -> + BodyText(mode.id.toString()) + BodyText(mode.width.toString()) + BodyText(mode.height.toString()) + BodyText(mode.fps) + BodyText(mode.hdr) + BodyText(mode.altFps) + } + } + + SubSectionTitle("Detected HDR Support") + Grid( + config = { + column(GridTrackSize.Auto) + column(GridTrackSize.Auto) + flow = GridFlow.Row + columnGap(8.dp) + rowGap(4.dp) + }, + ) { + EmphasizedBodyText("HDR Format") + EmphasizedBodyText("Supported") + state.hdrSupport.forEach { (name, supported) -> + BodyText(name) + BodyText(supported.toString()) + } + } } } item { @@ -565,11 +638,29 @@ private fun SubSectionTitle(text: String) { } @Composable -private fun BodyText(text: String) { +private fun BodyText( + text: String, + modifier: Modifier = Modifier, +) { Text( text = text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) +} + +@Composable +private fun EmphasizedBodyText( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = modifier, ) } @@ -601,3 +692,45 @@ private val THIRD_PARTY_TAGS = "HardwareConfig:W", "DfltImageHeaderParser:W", ) + +data class DisplayMode( + val id: Int, + val width: Int, + val height: Int, + val fps: String, + val hdr: String, + val altFps: String, +) + +fun Display.Mode.toDisplayMode(display: Display): DisplayMode { + val fps = String.format(Locale.getDefault(), "%.3f", refreshRate) + val altFps = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + alternativeRefreshRates.joinToString(", ") { + String.format(Locale.getDefault(), "%.3f", it) + } + } else { + "N/A" + } + val hdr = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + supportedHdrTypes.joinToString(", ") { hdrTypeToString(it) } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + display.hdrCapabilities?.supportedHdrTypes?.joinToString(", ") { hdrTypeToString(it) } + ?: "None found" + } else { + "N/A" + } + return DisplayMode(modeId, physicalWidth, physicalHeight, fps, hdr, altFps) +} + +fun hdrTypeToString(type: Int): String = + when (type) { + HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10" + HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10+" + HdrCapabilities.HDR_TYPE_HLG -> "HLG" + HdrCapabilities.HDR_TYPE_HLG_PLUS -> "HLG+" + HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DoVi" + HdrCapabilities.HDR_TYPE_INVALID -> "Invalid" + else -> "Unknown ($type)" + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt index adbd9a44..5690fadc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt @@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.util.goToRandom import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.DataLoadingState @@ -700,11 +701,25 @@ class FavoritesViewModel viewModelScope.launchIO { try { collectionStateFor(type)?.let { collectionState -> - val random = - (collectionState.items.successValue as? BlockingList)?.randomBlocking() - Timber.d("Got random item: %s", random?.id) - random?.destination()?.let { - navigateTo(random.destination()) + val list = + collectionState.items.successValue as? BlockingList + if (list != null) { + goToRandom( + navigateTo = ::navigateTo, + parentId = libraryDisplayItemId(type), + filter = + collectionState.filter.copy( + includeItemTypes = listOf(type), + ), + sortAndDirection = collectionState.sortAndDirection, + recursive = true, + list = list, + ) + } else { + Timber.w( + "state.value.items is not successful: %s", + collectionState.items, + ) } } } catch (ex: CancellationException) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt index 1fd7ea9f..4e49bd39 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -24,7 +24,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.HomeDataService import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavDrawerService @@ -34,7 +34,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem -import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.components.ContextMenuProvider import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -75,7 +74,7 @@ class HomeRowGridViewModel val serverRepository: ServerRepository, private val userPreferencesService: UserPreferencesService, private val navDrawerService: NavDrawerService, - private val homeSettingsService: HomeSettingsService, + private val homeDataService: HomeDataService, private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, private val navigationManager: NavigationManager, @@ -128,7 +127,7 @@ class HomeRowGridViewModel val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val result = - homeSettingsService.fetchDataForRow( + homeDataService.fetchDataForRow( row = rowConfig, scope = viewModelScope, prefs = prefs, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 27212b5e..64c48f0e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -257,6 +257,7 @@ fun PersonPage( imageUrlService.getItemImageUrl( itemId = person.id, imageType = ImageType.PRIMARY, + tag = person.data.imageTags?.get(ImageType.PRIMARY), ) } // A long filmography is hard to navigate as a row, so it is cut at the same diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index b7085db6..33960f83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -1,49 +1,26 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource @@ -51,60 +28,49 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import androidx.tv.material3.ListItem +import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.filter.DefaultPlaylistItemsOptions -import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.GetItemsFilter -import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.PlaylistCreator -import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.ItemCardImage +import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator +import com.github.damontecres.wholphin.ui.cards.WatchedIcon +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.components.CollectionFolderState +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewScope import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuActions import com.github.damontecres.wholphin.ui.components.ContextMenuDialog -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton -import com.github.damontecres.wholphin.ui.components.FilterByButton -import com.github.damontecres.wholphin.ui.components.GridTitle -import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.GridClickActions +import com.github.damontecres.wholphin.ui.components.ListItemWrapper import com.github.damontecres.wholphin.ui.components.MusicContextActions import com.github.damontecres.wholphin.ui.components.Optional -import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.SortByButton import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.components.ViewOptionsType import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions +import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.equalsNotNull -import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.formatTime -import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.settings.MoveDirection @@ -113,13 +79,9 @@ import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundSeconds import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -127,62 +89,62 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.playlistsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID -import kotlin.time.Duration @HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class) class PlaylistViewModel @AssistedInject constructor( + private val savedStateHandle: SavedStateHandle, @ApplicationContext context: Context, api: ApiClient, navigationManager: NavigationManager, musicService: MusicService, mediaManagementService: MediaManagementService, - private val backdropService: BackdropService, private val serverRepository: ServerRepository, - private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - private val favoriteWatchManager: FavoriteWatchManager, - private val serverReportService: ServerReportService, - private val filterOptionCache: FilterOptionCache, - private val playlistCreator: PlaylistCreator, @Assisted itemId: UUID, + @Assisted private val collectionState: StateFlow, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory interface Factory { - fun create(itemId: UUID): PlaylistViewModel + fun create( + itemId: UUID, + collectionState: StateFlow, + ): PlaylistViewModel } - val state = MutableStateFlow(PlaylistDetailsState()) + private val _state = MutableStateFlow(PlaylistDetailsState()) + val state: StateFlow = _state + val musicState = musicService.state + var position: Int + get() = savedStateHandle.get("position") ?: 0 + set(value) { + savedStateHandle["position"] = value + } + init { init() } override fun init() { - state.update { it.copy(loading = LoadingState.Loading) } viewModelScope.launchDefault { try { - val playlist = - api.userLibraryApi - .getItem(itemId) - .content - .let { BaseItem(it, false) } val user = serverRepository.currentUser val canEdit = user?.let { user -> @@ -215,95 +177,12 @@ class PlaylistViewModel false } } ?: false - state.update { - it.copy( - playlist = playlist, - canEdit = canEdit, - ) - } - val libraryDisplayInfo = - user?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId) - } - val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() - val sortAndDirection = - libraryDisplayInfo?.sortAndDirection ?: SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ) - loadItems(filter, sortAndDirection).join() + _state.update { it.copy(canEdit = canEdit) } determineMediaType() } catch (ex: Exception) { - Timber.e(ex, "Error fetching playlist %s", itemId) - state.update { it.copy(loading = LoadingState.Error(ex)) } - } - } - } - - fun loadItems( - filter: GetItemsFilter, - sortAndDirection: SortAndDirection, - ) = viewModelScope.launchIO { - backdropService.clearBackdrop() - state.update { - it.copy( - loading = LoadingState.Loading, - filterAndSort = FilterAndSort(filter, sortAndDirection), - ) - } - - serverRepository.currentUser?.let { user -> - viewModelScope.launchIO { - val libraryDisplayInfo = - libraryDisplayInfoDao.getItem(user, itemId)?.copy( - filter = filter, - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - ) - ?: LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId.toServerString(), - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - filter = filter, - viewOptions = null, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) - } - - val request = - filter.applyTo( - GetItemsRequest( - parentId = itemId, - userId = user.id, - fields = SlimItemFields, - sortBy = listOf(sortAndDirection.sort), - sortOrder = listOf(sortAndDirection.direction), - ), - ) - try { - val pager = - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - viewModelScope, - ).init() - state.update { - it.copy( - items = pager, - loading = LoadingState.Success, - ) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching playlist %s", itemId) - state.update { - it.copy( - items = emptyList(), - loading = LoadingState.Error(ex), - ) - } + Timber.e(ex, "Error fetching playlist info for %s", itemId) +// _state.update { it.copy(item = DataLoadingState.Error(ex)) } } } } @@ -313,77 +192,49 @@ class PlaylistViewModel * * In theory, the server will set the type, but sometimes it doesn't */ - private suspend fun determineMediaType() { - // Use the type the server says - var mediaType = - state.value.playlist - ?.data - ?.mediaType ?: MediaType.UNKNOWN - mediaType = - if (mediaType == MediaType.UNKNOWN) { - // Otherwise, if a most of the list is one type, we can assume that type - val pager = (state.value.items as? ApiRequestPager<*>) - if (pager != null && pager.size <= 50) { - val types = - (0..<50.coerceAtMost(pager.size)).groupBy { index -> - val pagerItem = pager.getBlocking(index) - when (pagerItem?.type) { - BaseItemKind.AUDIO -> MediaType.AUDIO + private fun determineMediaType() { + collectionState + .map { Pair(it.item, it.items) } + .distinctUntilChanged() + .catch { ex -> Timber.e(ex, "Error in determineMediaType") } + .collectLatestIn(viewModelScope) { (item, items) -> + var mediaType = item.successValue?.data?.mediaType ?: MediaType.UNKNOWN + mediaType = + if (mediaType == MediaType.UNKNOWN) { + // Otherwise, if a most of the list is one type, we can assume that type + val pager = (items.successValue as? ApiRequestPager<*>) + if (pager != null && pager.size <= 50) { + val types = + (0..<50.coerceAtMost(pager.size)).groupBy { index -> + val pagerItem = pager.getBlocking(index) + when (pagerItem?.type) { + BaseItemKind.AUDIO -> MediaType.AUDIO - BaseItemKind.VIDEO, - BaseItemKind.EPISODE, - BaseItemKind.MOVIE, - BaseItemKind.BOX_SET, - -> MediaType.VIDEO + BaseItemKind.VIDEO, + BaseItemKind.EPISODE, + BaseItemKind.MOVIE, + BaseItemKind.BOX_SET, + -> MediaType.VIDEO - else -> MediaType.UNKNOWN + else -> MediaType.UNKNOWN + } + } + if (types.keys.size == 1) { + types.keys.first() + } else { + MediaType.UNKNOWN } + } else { + MediaType.UNKNOWN } - if (types.keys.size == 1) { - types.keys.first() } else { - MediaType.UNKNOWN + mediaType } - } else { - MediaType.UNKNOWN + Timber.d("mediaType=%s", mediaType) + _state.update { + it.copy(mediaType = mediaType) } - } else { - mediaType } - Timber.d("mediaType=%s", mediaType) - state.update { - it.copy(mediaType = mediaType) - } - } - - suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - filterOptionCache.getFilterOptionValues( - itemId, - filterOption, - ) - - fun updateBackdrop(item: BaseItem) { - viewModelScope.launchIO { - backdropService.submit(item) - } - } - - fun setWatched( - itemId: UUID, - played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - } - - fun setFavorite( - itemId: UUID, - favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - } - - fun sendMediaReport(itemId: UUID) { - viewModelScope.launchDefault { serverReportService.sendMediaReportFor(itemId) } } fun removeFromPlaylist( @@ -392,11 +243,12 @@ class PlaylistViewModel ) { viewModelScope.launchIO { try { - playlistCreator.removeFromServerPlaylist( - playlistId = this@PlaylistViewModel.itemId, - itemId = itemId, + api.playlistsApi.removeItemFromPlaylist( + this@PlaylistViewModel.itemId.toServerString(), + listOf(itemId.toServerString()), ) - (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index) + (collectionState.value.items.successValue as? ApiRequestPager<*>) + ?.refreshPagesAfter(index) } catch (ex: CancellationException) { throw ex } catch (ex: Exception) { @@ -423,31 +275,15 @@ class PlaylistViewModel itemId = itemId.toServerString(), newIndex = newIndex, ) - (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index - 1) + (collectionState.value.items.successValue as? ApiRequestPager<*>)?.refreshPagesAfter( + index - 1, + ) } } } -@Immutable -data class FilterAndSort( - val filter: GetItemsFilter, - val sortAndDirection: SortAndDirection, -) - data class PlaylistDetailsState( - val playlist: BaseItem? = null, val mediaType: MediaType = MediaType.UNKNOWN, - val items: List = emptyList(), - val filterAndSort: FilterAndSort = - FilterAndSort( - filter = GetItemsFilter(), - sortAndDirection = - SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ), - ), - val loading: LoadingState = LoadingState.Pending, val canEdit: Boolean = false, ) @@ -456,14 +292,33 @@ fun PlaylistDetails( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, + collectionViewModel: CollectionFolderViewModel = + hiltViewModel( + creationCallback = { + it.create( + itemId = destination.itemId.toServerString(), + initialSortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + recursive = false, + collectionFilter = CollectionFolderFilter(), + useSeriesForPrimary = false, + defaultViewOptions = playlistDefaultViewOptions, + ) + }, + ), viewModel: PlaylistViewModel = hiltViewModel( - creationCallback = { it.create(destination.itemId) }, + creationCallback = { it.create(destination.itemId, collectionViewModel.state) }, ), addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsState() + val state by collectionViewModel.state.collectAsState() + val playlistState by viewModel.state.collectAsState() val musicState by viewModel.musicState.collectAsState() + val focusManager = LocalFocusManager.current var showContextMenu by remember { mutableStateOf(null) } var showConfirmTypeDialog by remember { mutableStateOf?>(null) } @@ -476,15 +331,15 @@ fun PlaylistDetails( shuffle: Boolean, mediaTypeOverride: MediaType? = null, ) { - when (mediaTypeOverride ?: state.mediaType) { + when (mediaTypeOverride ?: playlistState.mediaType) { MediaType.VIDEO -> { - viewModel.navigationManager.navigateTo( + collectionViewModel.navigateTo( Destination.PlaybackList( itemId = destination.itemId, startIndex = index, shuffle = shuffle, - filter = state.filterAndSort.filter, - sortAndDirection = state.filterAndSort.sortAndDirection, + filter = state.filter, + sortAndDirection = state.sortAndDirection, ), ) } @@ -498,93 +353,152 @@ fun PlaylistDetails( } } } - val musicContextActions = - remember { - MusicContextActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) }, - onClickPlayNext = { index, item -> viewModel.playNext(item) }, - onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, - onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, - onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onClickRemoveFromQueue = { _, _ -> }, - onDeleteItem = viewModel::deleteItem, - onRemoveFromPlaylist = viewModel::removeFromPlaylist, - ) - } - val contextActions = - remember { - ContextMenuActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - onClickWatch = { id, watched -> viewModel.setWatched(id, watched) }, - onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, - onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel::sendMediaReport, - onDeleteItem = viewModel::deleteItem, - onClickAddToQueue = { viewModel.addToQueue(it, 0) }, - onShowOverview = {}, - onChooseVersion = { _, _ -> }, - onChooseTracks = {}, - onClearChosenStreams = {}, - onClickRemoveFromNextUp = {}, - onRemoveFromPlaylist = viewModel::removeFromPlaylist, - ) - } - PlaylistDetailsContent( - loadingState = state.loading, - playlist = state.playlist, - items = state.items, - musicState = musicState, - onChangeBackdrop = viewModel::updateBackdrop, - onClickIndex = { index, item -> - play(index, item, false) - }, - onClickPlay = { shuffle -> - state.playlist?.let { - play(0, it, shuffle) + fun showContextMenu( + index: Int, + item: BaseItem, + fromLongClick: Boolean, + ) { + showContextMenu = + if (item.type == BaseItemKind.AUDIO) { + val musicContextActions = + MusicContextActions( + navigateTo = { collectionViewModel.navigateTo(it) }, + onClickPlay = { index, item -> + play( + index, + item, + false, + MediaType.AUDIO, + ) + }, + onClickPlayNext = { index, item -> viewModel.playNext(item) }, + onClickAddToQueue = { item -> + viewModel.addToQueue( + item, + Int.MAX_VALUE, + ) + }, + onClickFavorite = { id, favorite -> + collectionViewModel.setFavorite(index, id, favorite) + }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = { collectionViewModel.deleteItem(index, item) }, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, + ) + ContextMenu.ForMusic( + fromLongClick = fromLongClick, + item = item, + index = index, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveFromQueue = false, + actions = musicContextActions, + showRemoveFromPlaylist = playlistState.canEdit, + ) + } else { + val contextActions = + ContextMenuActions( + navigateTo = { collectionViewModel.navigateTo(it) }, + onClickWatch = { id, watched -> + collectionViewModel.setWatched( + index, + id, + watched, + ) + }, + onClickFavorite = { id, favorite -> + collectionViewModel.setFavorite(index, id, favorite) + }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = collectionViewModel::sendReportFor, + onDeleteItem = { collectionViewModel.deleteItem(index, item) }, + onClickAddToQueue = { viewModel.addToQueue(it, 0) }, + onShowOverview = {}, + onChooseVersion = { _, _ -> }, + onChooseTracks = {}, + onClearChosenStreams = {}, + onClickRemoveFromNextUp = {}, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, + ) + ContextMenu.ForBaseItem( + fromLongClick = fromLongClick, + item = item, + index = index, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + showRemoveFromPlaylist = playlistState.canEdit, + ) } - }, - onShowContextMenu = { index, item, fromLongClick -> - showContextMenu = - if (item.type == BaseItemKind.AUDIO) { - ContextMenu.ForMusic( - fromLongClick = fromLongClick, - item = item, - index = index, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveFromQueue = false, - actions = musicContextActions, - showRemoveFromPlaylist = state.canEdit, - ) - } else { - ContextMenu.ForBaseItem( - fromLongClick = fromLongClick, - item = item, - index = index, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - showRemoveFromPlaylist = state.canEdit, - ) - } - }, - filterAndSort = state.filterAndSort, - onFilterAndSortChange = viewModel::loadItems, - getPossibleFilterValues = viewModel::getFilterOptionValues, - canEdit = state.canEdit, - onMoveItem = viewModel::onMoveItem, + } + + val actions = + remember { + GridClickActions( + onClickItem = { index, item -> collectionViewModel.navigateTo(item.destination(index)) }, + onLongClickItem = { index, item -> showContextMenu(index, item, true) }, + onClickPlayAll = { shuffle -> + state.item.successValue?.let { playlist -> play(0, playlist, shuffle) } + }, + onClickPlayRemoteButton = { index, item -> play(index, item, false) }, + ) + } + CollectionFolderViewContent( + preferences = preferences, + state = state, + savedPosition = viewModel.position, + itemId = destination.itemId.toServerString(), + initialFilter = CollectionFolderFilter(filter = state.filter), + recursive = false, + actions = actions, + sortOptions = PlaylistSortOptions, + playEnabled = true, + defaultViewOptions = playlistDefaultViewOptions, + viewActions = collectionViewModel, + provider = collectionViewModel, + positionCallback = { _, position -> viewModel.position = position }, modifier = modifier, + listItemContent = { (item, index, onClick, onLongClick, _, mod) -> + PlaylistItem( + item = item, + index = index, + onClick = onClick, + onLongClick = onLongClick, + canMove = playlistState.canEdit, + moveUpAllowed = index > 0, + moveDownAllowed = + state.items.successValue + ?.lastIndex + ?.gt(index) ?: false, + onClickMove = { direction -> + item?.let { viewModel.onMoveItem(index, item.id, direction) } + when (direction) { + MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up) + MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down) + } + }, + onClickMore = { item?.let { showContextMenu(index, item, false) } }, + modifier = mod, +// interactionSource = TODO(), + isPlaying = + equalsNotNull( + musicState.currentItemId, + item?.id, + ), + isQueued = remember(musicState.queueVersion) { item?.id in musicState.queuedIds }, + ) + }, ) showContextMenu?.let { contextMenu -> ContextMenuDialog( @@ -621,294 +535,7 @@ fun PlaylistDetails( } @Composable -fun PlaylistDetailsContent( - playlist: BaseItem?, - items: List, - musicState: MusicServiceState, - onClickIndex: (Int, BaseItem) -> Unit, - onShowContextMenu: (Int, BaseItem, Boolean) -> Unit, - onClickPlay: (shuffle: Boolean) -> Unit, - onChangeBackdrop: (BaseItem) -> Unit, - onMoveItem: (Int, UUID, MoveDirection) -> Unit, - filterAndSort: FilterAndSort, - onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, - getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, - loadingState: LoadingState, - canEdit: Boolean, - modifier: Modifier = Modifier, -) { - var savedIndex by rememberSaveable { mutableIntStateOf(0) } - var focusedIndex by remember { mutableIntStateOf(savedIndex) } - val focusedItem = items.getOrNull(focusedIndex) - LaunchedEffect(focusedItem) { - focusedItem?.let(onChangeBackdrop) - } - val focusRequester = remember { FocusRequester() } - LaunchedEffect(loadingState) { - if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) { - focusRequester.tryRequestFocus() - } - } - - val playButtonFocusRequester = remember { FocusRequester() } - - Box( - modifier = modifier, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = - Modifier - .fillMaxSize(), - ) { - val title = - if (loadingState is LoadingState.Success) { - playlist?.name ?: stringResource(R.string.playlist) - } else { - "" - } - GridTitle(title) - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), - modifier = - Modifier - .fillMaxWidth(), - ) { - PlaylistDetailsHeader( - focusedItem = focusedItem, - onClickPlay = onClickPlay, - playButtonFocusRequester = playButtonFocusRequester, - focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() }, - filterAndSort = filterAndSort, - onFilterAndSortChange = onFilterAndSortChange, - getPossibleFilterValues = getPossibleFilterValues, - filterOptions = DefaultPlaylistItemsOptions, - modifier = - Modifier - .padding(top = 80.dp) - .fillMaxWidth(.25f), - ) - val filterCount = - remember(filterAndSort) { - filterAndSort.filter.countFilters(DefaultPlaylistItemsOptions) - } - PlaylistItems( - loadingState = loadingState, - items = items, - musicState = musicState, - playButtonFocusRequester = playButtonFocusRequester, - onFocusItem = { index, item -> - focusedIndex = index - }, - onClickItem = { index, item -> - savedIndex = index - item?.let { onClickIndex.invoke(index, item) } - }, - onShowContextMenu = { index, item, fromLongClick -> - savedIndex = index - item?.let { onShowContextMenu.invoke(index, item, fromLongClick) } - }, - canMove = canEdit && filterAndSort.sortAndDirection.sort == ItemSortBy.DEFAULT && filterCount == 0, - onMoveItem = onMoveItem, - modifier = - Modifier - .padding(start = 16.dp) - .weight(1f) - .focusRequester(focusRequester) - .focusProperties { - onExit = { - playButtonFocusRequester.tryRequestFocus() - } - }, - ) - } - } - } -} - -@Composable -fun PlaylistDetailsHeader( - focusedItem: BaseItem?, - filterOptions: List>, - onClickPlay: (shuffle: Boolean) -> Unit, - playButtonFocusRequester: FocusRequester, - focusRequester: FocusRequester, - filterAndSort: FilterAndSort, - onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, - getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, - modifier: Modifier = Modifier, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier, - ) { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = { onClickPlay.invoke(false) }, - modifier = Modifier.focusRequester(playButtonFocusRequester), - ) - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { onClickPlay.invoke(true) }, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier, - ) { - FilterByButton( - filterOptions = filterOptions, - current = filterAndSort.filter, - onFilterChange = { - onFilterAndSortChange.invoke( - it, - filterAndSort.sortAndDirection, - ) - }, - getPossibleValues = getPossibleFilterValues, - modifier = Modifier.focusRequester(focusRequester), - ) - SortByButton( - sortOptions = BoxSetSortOptions, - current = filterAndSort.sortAndDirection, - onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) }, - ) - } - Text( - text = focusedItem?.title ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, - ) - Text( - text = focusedItem?.subtitle ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium, - ) - if (focusedItem?.type == BaseItemKind.EPISODE && focusedItem.data.premiereDate != null) { - Text( - text = formatDateTime(focusedItem.data.premiereDate!!), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleSmall, - ) - } - OverviewText( - overview = focusedItem?.data?.overview ?: "", - maxLines = 10, - onClick = {}, - enabled = false, - ) - } -} - -@Composable -fun PlaylistItems( - loadingState: LoadingState, - items: List, - musicState: MusicServiceState, - playButtonFocusRequester: FocusRequester, - canMove: Boolean, - onMoveItem: (Int, UUID, MoveDirection) -> Unit, - onFocusItem: (Int, BaseItem?) -> Unit, - onClickItem: (Int, BaseItem?) -> Unit, - onShowContextMenu: (Int, BaseItem?, Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - val focusManager = LocalFocusManager.current - when (loadingState) { - is LoadingState.Error -> { - ErrorMessage(loadingState, modifier) - } - - LoadingState.Pending, LoadingState.Loading -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - if (items.isNotEmpty()) { - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - modifier - .padding(bottom = 32.dp) - .fillMaxHeight() - .background( - MaterialTheme.colorScheme - .surfaceColorAtElevation(1.dp) - .copy(alpha = .75f), - shape = RoundedCornerShape(16.dp), - ).focusGroup() - .focusRestorer(), - ) { - itemsIndexed(items) { index, item -> - PlaylistItem( - item = item, - index = index, - onClick = { - onClickItem.invoke(index, item) - }, - onLongClick = { - onShowContextMenu.invoke(index, item, true) - }, - isPlaying = - equalsNotNull( - musicState.currentItemId, - item?.id, - ), - isQueued = item?.id in musicState.queuedIds, - canMove = canMove, - moveUpAllowed = index > 0, - moveDownAllowed = index < items.lastIndex, - onClickMove = { direction -> - item?.let { onMoveItem.invoke(index, item.id, direction) } - when (direction) { - MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up) - MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down) - } - }, - onClickMore = { onShowContextMenu.invoke(index, item, false) }, - modifier = - Modifier - .animateItem() - .ifElse( - item?.type != BaseItemKind.AUDIO, - Modifier.height(80.dp), - ).onFocusChanged { - if (it.hasFocus) { - onFocusItem(index, item) - } - }, - ) - } - } - } else { - LaunchedEffect(Unit) { - playButtonFocusRequester.tryRequestFocus() - } - Box( - contentAlignment = Alignment.Center, - modifier = modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.no_results), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier, - ) - } - } - } - } -} - -@Composable -fun PlaylistItem( +fun CollectionFolderViewScope.PlaylistItem( item: BaseItem?, index: Int, onClick: () -> Unit, @@ -923,19 +550,18 @@ fun PlaylistItem( isPlaying: Boolean = false, isQueued: Boolean = false, ) { + val dense = viewOptions.type == ViewOptionsType.DENSE_LIST val focused by interactionSource.collectIsFocusedAsState() - val imageWidth = 160.dp - val density = LocalDensity.current - val imageWidthPx = remember(imageWidth, density) { with(density) { imageWidth.roundToPx() } } Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, modifier = modifier - .fillMaxWidth() - .heightIn(min = 40.dp, max = 88.dp), + .fillMaxWidth(), +// .heightIn(min = 40.dp, max = 88.dp), ) { - ListItem( + ListItemWrapper( + dense = dense, selected = false, onClick = onClick, onLongClick = onLongClick, @@ -954,19 +580,21 @@ fun PlaylistItem( }, trailingContent = { val duration = - when (item?.type) { - BaseItemKind.AUDIO -> { - item.data.runTimeTicks - ?.ticks - ?.roundSeconds - } + remember(item?.data?.runTimeTicks) { + when (item?.type) { + BaseItemKind.AUDIO -> { + item.data.runTimeTicks + ?.ticks + ?.roundSeconds + } - else -> { - item - ?.data - ?.runTimeTicks - ?.ticks - ?.roundMinutes + else -> { + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + } } } duration?.let { duration -> @@ -980,15 +608,25 @@ fun PlaylistItem( val resources = LocalResources.current val durationText = remember(resources, duration) { resources.formatDuration(duration) } - Column { - Text( - text = durationText, - ) - if (item?.type != BaseItemKind.AUDIO) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column { Text( - text = stringResource(R.string.ends_at, endTimeStr), - style = MaterialTheme.typography.bodySmall, + text = durationText, ) + if (item?.type != BaseItemKind.AUDIO && !dense) { + val style = + LocalTextStyle.current.let { it.copy(fontSize = it.fontSize * .75) } + Text( + text = stringResource(R.string.ends_at, endTimeStr), + style = style, + ) + } + } + if (item?.played == true) { + WatchedIcon(padding = 0.dp) } } } @@ -996,41 +634,29 @@ fun PlaylistItem( leadingContent = { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = "${index + 1}.", - style = MaterialTheme.typography.labelLarge, - ) + if (sortAndDirection.sort == ItemSortBy.DEFAULT) { +// val indexText = +// remember(index, collectionSize, sortAndDirection) { +// when (sortAndDirection.direction) { +// SortOrder.ASCENDING -> "${index + 1}." +// SortOrder.DESCENDING -> "${collectionSize - index}." +// } +// } + // TODO default descending doesn't work? + val indexText = "${index + 1}." + Text( + text = indexText, + ) + } if (item?.type == BaseItemKind.AUDIO) { MusicQueueMarker( isPlaying = isPlaying, isQueued = isQueued, ) - } else { - val imageType = - remember(item) { - if (item != null && ImageType.THUMB in item.data.imageTags.orEmpty()) { - ImageType.THUMB - } else { - ImageType.PRIMARY - } - } - ItemCardImage( - item = item, - name = item?.name, - imageType = imageType, - showOverlay = true, - favorite = item?.data?.userData?.isFavorite ?: false, - watched = item?.data?.userData?.played ?: false, - unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, - watchedPercent = 0.0, - numberOfVersions = item?.data?.mediaSourceCount ?: 0, - modifier = Modifier.width(imageWidth), - useFallbackText = false, - fillWidth = imageWidthPx, - ) } + if (item?.favorite == true) FavoriteIndicator() } }, modifier = Modifier.weight(1f), @@ -1120,3 +746,10 @@ fun ConfirmMediaTypeDialog( } } } + +private val playlistDefaultViewOptions = + ViewOptions( + spacing = 0, + showDetails = true, + type = ViewOptionsType.LIST, + ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index c5686e8f..d0017d84 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -42,9 +42,8 @@ import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.LocalTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.SeerrAvailability -import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.data.model.canRequestMovies import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService @@ -84,14 +83,12 @@ fun DiscoverMovieDetails( } val state by viewModel.state.collectAsState() val userConfig by viewModel.userConfig.collectAsState(null) + val requestEnabled by viewModel.requestEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) var overviewDialog by remember { mutableStateOf(null) } var showRequestDialog by remember { mutableStateOf(false) } - val requestStr = stringResource(R.string.request) - val request4kStr = stringResource(R.string.request_4k) - when (val st = state.movie) { is DataLoadingState.Error -> { ErrorMessage(st, modifier) @@ -153,6 +150,7 @@ fun DiscoverMovieDetails( RequestMovieDialog( loading = state.profileLoading, data = state.requestData, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, movie = movie, onSubmit = { @@ -253,7 +251,7 @@ fun DiscoverMovieDetailsContent( } } }, - canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canRequest = userConfig.canRequestMovies, canCancel = canCancel, trailers = trailers, trailerOnClick = trailerOnClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index 658e4ffe..407c6935 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -75,6 +75,8 @@ class DiscoverMovieViewModel val state: StateFlow = _state val userConfig = seerrServerRepository.current.map { it?.config } + val requestEnabled = + seerrServerRepository.current.map { it?.requestMovieEnabled ?: false } val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } @@ -194,7 +196,7 @@ class DiscoverMovieViewModel return@launchIO } try { - seerrService.api.requestApi.requestPost( + val postRequest = RequestPostRequest( is4k = request.is4k, mediaId = request.movieId, @@ -208,8 +210,9 @@ class DiscoverMovieViewModel profileId = request.profileId, rootFolder = request.folder, tags = emptyList(), - ), - ) + ) + Timber.v("postRequest=%s", postRequest) + seerrService.api.requestApi.requestPost(postRequest) } catch (ex: CancellationException) { throw ex } catch (ex: Exception) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index ef96bb7e..860af84e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -37,9 +37,8 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.QuickDetailsData import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.SeerrAvailability -import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.data.model.canRequestTv import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService @@ -88,6 +87,7 @@ fun DiscoverSeriesDetails( val context = LocalContext.current val resources = LocalResources.current val state by viewModel.state.collectAsState() + val requestEnabled by viewModel.requestEnabled.collectAsState() val request4kEnabled by viewModel.request4kEnabled.collectAsState() var overviewDialog by remember { mutableStateOf(null) } @@ -112,6 +112,7 @@ fun DiscoverSeriesDetails( DiscoverSeriesDetailsContent( preferences = preferences, series = item, + availability = state.availability, userConfig = userConfig, rating = state.rating, canCancel = state.canCancelRequest, @@ -188,6 +189,7 @@ fun DiscoverSeriesDetails( title = state.tvSeries.successValue?.name ?: "", seasons = state.seasons, seasons4k = state.seasons4k, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, onSubmit = { showRequestSeasonDialog = false @@ -212,6 +214,7 @@ fun DiscoverSeriesDetailsContent( preferences: UserPreferences, userConfig: SeerrUserConfig?, series: TvDetails, + availability: SeerrAvailability, rating: SourceRatings?, canCancel: Boolean, seasons: List, @@ -275,9 +278,7 @@ fun DiscoverSeriesDetailsContent( .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandableDiscoverButtons( - availability = - SeerrAvailability.from(series.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, + availability = availability, requestOnClick = requestOnClick, pendingOnClick = requestOnClick, cancelOnClick = cancelOnClick, @@ -291,7 +292,7 @@ fun DiscoverSeriesDetailsContent( } } }, - canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canRequest = userConfig.canRequestTv, canCancel = canCancel, trailers = trailers, trailerOnClick = trailerOnClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index b4d6f37f..c20ab9c9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -75,6 +75,10 @@ class DiscoverSeriesViewModel val state: StateFlow = _state val userConfig = seerrServerRepository.current.map { it?.config } + val requestEnabled = + seerrServerRepository.current + .map { it?.requestTvEnabled ?: false } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) val request4kEnabled = seerrServerRepository.current .map { it?.request4kTvEnabled ?: false } @@ -108,7 +112,7 @@ class DiscoverSeriesViewModel backdropService.submit(discoveredItem) updateSeasonStatus(tv) - updateCanCancel() + updateAvailability() viewModelScope.launchIO { val rating = @@ -190,6 +194,7 @@ class DiscoverSeriesViewModel tv: TvDetails, is4k: Boolean, ) { + Timber.v("updateSeasonStatus: id=%s, is4k=%s", tv.id, is4k) val currentUserId = seerrServerRepository.currentUserId.first() val seasonStatus = mutableMapOf() val seasonAvailability = mutableMapOf() @@ -230,7 +235,10 @@ class DiscoverSeriesViewModel seasonStatus[season.seasonNumber] = new } editable[season.seasonNumber] = - currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status + currentUserId == req.requestedBy?.id && ( + req.status == RequestStatus.PENDING.status || + req.status == RequestStatus.DECLINED.status + ) } } } @@ -299,7 +307,7 @@ class DiscoverSeriesViewModel } } - private suspend fun updateCanCancel() { + private suspend fun updateAvailability() { val user = userConfig.firstOrNull() val canCancel = canUserCancelRequest( @@ -308,7 +316,32 @@ class DiscoverSeriesViewModel ?.mediaInfo ?.requests, ) - _state.update { it.copy(canCancelRequest = canCancel) } + val status = + state.value.tvSeries.successValue?.let { tv -> + val overallStatus = SeerrAvailability.from(tv.mediaInfo?.status) + if (overallStatus == SeerrAvailability.UNKNOWN) { + val userRequests = + tv.mediaInfo + ?.requests + ?.filter { it.requestedBy?.id == user?.id } + .orEmpty() + if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.PENDING }) { + SeerrAvailability.PENDING + } else if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.APPROVED }) { + SeerrAvailability.PROCESSING + } else { + SeerrAvailability.UNKNOWN + } + } else { + overallStatus + } + } + _state.update { + it.copy( + canCancelRequest = canCancel, + availability = status ?: SeerrAvailability.UNKNOWN, + ) + } } fun requestOnClick() { @@ -391,7 +424,7 @@ class DiscoverSeriesViewModel fetchAndSetItem().await()?.let { updateSeasonStatus(it) - updateCanCancel() + updateAvailability() } } } @@ -412,7 +445,7 @@ class DiscoverSeriesViewModel fetchAndSetItem().await()?.let { updateSeasonStatus(it) - updateCanCancel() + updateAvailability() } } } @@ -431,4 +464,5 @@ data class DiscoverSeriesState( val canCancelRequest: Boolean = false, val profileLoading: LoadingState = LoadingState.Pending, val requestData: SeerrRequestData = SeerrRequestData(), + val availability: SeerrAvailability = SeerrAvailability.UNKNOWN, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt index 7a009f09..ef6e54b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt @@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.util.LoadingState fun RequestMovieDialog( loading: LoadingState, data: SeerrRequestData, + requestEnabled: Boolean, request4kEnabled: Boolean, movie: MovieDetails, onSubmit: (MovieRequest) -> Unit, @@ -54,10 +55,11 @@ fun RequestMovieDialog( RequestMovie( loading = loading, data = data, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, movie = movie, onSubmit = onSubmit, - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(24.dp), ) } } @@ -66,6 +68,7 @@ fun RequestMovieDialog( fun RequestMovie( loading: LoadingState, data: SeerrRequestData, + requestEnabled: Boolean, request4kEnabled: Boolean, movie: MovieDetails, onSubmit: (MovieRequest) -> Unit, @@ -90,7 +93,8 @@ fun RequestMovie( if (data.profiles.isEmpty() && data.rootFolders.isEmpty() && data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty() ) { - if (request4kEnabled) { + // Both enabled, show choice + if (requestEnabled && request4kEnabled) { val items = remember { listOf( @@ -118,12 +122,13 @@ fun RequestMovie( ) } else { LaunchedEffect(Unit) { - onSubmit.invoke(MovieRequest(data, movie.id, false, null, null)) + onSubmit.invoke(MovieRequest(data, movie.id, request4kEnabled, null, null)) } } } else { RequestMovieWithOptions( data = data, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, movie = movie, onSubmit = onSubmit, @@ -137,12 +142,13 @@ fun RequestMovie( @Composable private fun RequestMovieWithOptions( data: SeerrRequestData, + requestEnabled: Boolean, request4kEnabled: Boolean, movie: MovieDetails, onSubmit: (MovieRequest) -> Unit, modifier: Modifier = Modifier, ) { - var is4k by remember { mutableStateOf(request4kEnabled) } + var is4k by remember { mutableStateOf(!requestEnabled) } var profile by remember(is4k) { mutableStateOf( if (is4k) { @@ -185,7 +191,7 @@ private fun RequestMovieWithOptions( .fillMaxWidth() .padding(bottom = 8.dp), ) { - if (request4kEnabled) { + if (requestEnabled && request4kEnabled) { ClickSwitch( label = stringResource(R.string.request_4k), checked = is4k, @@ -377,7 +383,7 @@ fun MovieRequestPreview() { ), profiles4k = listOf( - SeerrProfile(1, "HD", true), + SeerrProfile(1, "4K", true), SeerrProfile(2, "Ultra HD", false), ), rootFolders4k = @@ -385,7 +391,8 @@ fun MovieRequestPreview() { SeerrRootFolder(1, "/movies", "400GB", true), ), ), - request4kEnabled = false, + requestEnabled = true, + request4kEnabled = true, movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"), onSubmit = {}, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt index bc11825f..91a10279 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -65,11 +65,12 @@ fun RequestSeasons( seasons: List, seasons4k: List, data: SeerrRequestData, + requestEnabled: Boolean, request4kEnabled: Boolean, onSubmit: (TvRequest) -> Unit, modifier: Modifier = Modifier, ) { - var is4k by remember { mutableStateOf(request4kEnabled) } + var is4k by remember { mutableStateOf(!requestEnabled) } val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons } val allSeasonNumbers = @@ -161,7 +162,7 @@ fun RequestSeasons( .fillMaxWidth() .padding(vertical = 8.dp), ) { - if (request4kEnabled) { + if (requestEnabled && request4kEnabled) { ClickSwitch( label = stringResource(R.string.request_4k), checked = is4k, @@ -391,6 +392,7 @@ fun RequestSeasonsDialog( data: SeerrRequestData, seasons: List, seasons4k: List, + requestEnabled: Boolean, request4kEnabled: Boolean, onSubmit: (TvRequest) -> Unit, onDismissRequest: () -> Unit, @@ -418,6 +420,7 @@ fun RequestSeasonsDialog( data = data, seasons = seasons, seasons4k = seasons4k, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, onSubmit = onSubmit, modifier = @@ -475,6 +478,7 @@ fun RequestSeasonsPreview() { SeerrRootFolder(1, "/tv", "400GB", true), ), ), + requestEnabled = true, request4kEnabled = true, onSubmit = { }, modifier = Modifier.width(400.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index a45fbda0..97aaaf86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -145,11 +145,10 @@ class EpisodeViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -163,7 +162,6 @@ class EpisodeViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -171,10 +169,7 @@ class EpisodeViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) _state.update { it.copy(chosenStreams = chosen) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 470f33c2..bcba145c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -149,7 +149,11 @@ class LiveTvViewModel number = it.channelNumber, name = it.channelName ?: it.name, imageUrl = - imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY), + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.PRIMARY, + tag = it.imageTags?.get(ImageType.PRIMARY), + ), favorite = it.userData?.isFavorite == true, ) } @@ -299,6 +303,7 @@ class LiveTvViewModel imageUrlService.getItemImageUrl( dto.id, ImageType.PRIMARY, + tag = dto.imageTags?.get(ImageType.PRIMARY), ), ) if (index == 0) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index db6a6f6a..9a8dd033 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -223,11 +223,10 @@ class MovieViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -241,7 +240,6 @@ class MovieViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -249,10 +247,7 @@ class MovieViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) _state.update { it.copy(chosenStreams = chosen) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 32f1d484..3bbc3a6b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -561,11 +561,10 @@ class SeriesViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -579,7 +578,6 @@ class SeriesViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -587,10 +585,7 @@ class SeriesViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) _state.update { it.copy(chosenStreams = chosen) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 847e2e69..d02ba907 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.HomeDataService import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.LatestNextUpService @@ -62,6 +63,7 @@ class HomeViewModel val serverReportService: ServerReportService, private val navDrawerService: NavDrawerService, private val homeSettingsService: HomeSettingsService, + private val homeDataService: HomeDataService, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, private val backdropService: BackdropService, @@ -186,7 +188,7 @@ class HomeViewModel semaphore.withPermit { Timber.v("Fetching row: %s", row) try { - homeSettingsService.fetchDataForRow( + homeDataService.fetchDataForRow( row = row.config, scope = viewModelScope, prefs = prefs, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt index ad433c23..ae958a93 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt @@ -33,6 +33,9 @@ sealed interface HomeSettingsDestination : NavKey { @Serializable data object GlobalSettings : HomeSettingsDestination + @Serializable + data object SourceSettings : HomeSettingsDestination + @Serializable data object Presets : HomeSettingsDestination } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt index 894980c4..74c546b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -31,10 +31,6 @@ fun HomeSettingsGlobal( preferences: AppPreferences, onPreferenceChange: (AppPreferences) -> Unit, onClickResize: (Int) -> Unit, - onClickSave: () -> Unit, - onClickLoad: () -> Unit, - onClickLoadWeb: () -> Unit, - onClickReset: () -> Unit, onClickViewNextUp: () -> Unit, modifier: Modifier = Modifier, ) { @@ -146,62 +142,6 @@ fun HomeSettingsGlobal( modifier = Modifier, ) } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.save_to_server), - leadingContent = { - Text( - text = stringResource(R.string.fa_cloud_arrow_up), - fontFamily = FontAwesome, - ) - }, - onClick = onClickSave, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.load_from_server), - leadingContent = { - Text( - text = stringResource(R.string.fa_cloud_arrow_down), - fontFamily = FontAwesome, - ) - }, - onClick = onClickLoad, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.load_from_web_client), - leadingContent = { - Text( - text = stringResource(R.string.fa_download), - fontFamily = FontAwesome, - ) - }, - onClick = onClickLoadWeb, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.reset), - leadingContent = { - Text( - text = stringResource(R.string.fa_arrows_rotate), - fontFamily = FontAwesome, - ) - }, - onClick = onClickReset, - modifier = Modifier, - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 5a2d6c78..0bd6b7db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.main.settings +import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -64,11 +65,14 @@ fun HomeSettingsPage( var showConfirmDialog by remember { mutableStateOf(null) } var searchForDialog by remember { mutableStateOf(null) } var showRemovedNextUpDialog by remember { mutableStateOf(false) } + var showSaveDialog by remember { mutableStateOf(false) } val state by viewModel.state.collectAsState() + val serverPluginActive by viewModel.serverPluginActive.collectAsState() var position by rememberPosition(0, 0) // TODO discover rows val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) + val settingsHaveChanged by viewModel.settingsChanged.collectAsState() // Adds a row, waits until its done loading, then scrolls to the new row fun addRow( @@ -86,6 +90,10 @@ fun HomeSettingsPage( } } + BackHandler(settingsHaveChanged) { + showSaveDialog = true + } + Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, @@ -137,6 +145,7 @@ fun HomeSettingsPage( position = RowColumn(index, 0) } }, + onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) }, modifier = destModifier, ) } @@ -282,6 +291,16 @@ fun HomeSettingsPage( } }, onClickResize = { viewModel.resizeCards(it) }, + onClickViewNextUp = { + showRemovedNextUpDialog = true + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.SourceSettings -> { + HomeSettingsSourcePage( + source = state.source, onClickSave = { showConfirmDialog = ShowConfirm(R.string.overwrite_server_settings) { @@ -294,6 +313,13 @@ fun HomeSettingsPage( viewModel.loadFromRemote() } }, + serverPluginActive = serverPluginActive, + onClickLoadPlugin = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.loadFromRemotePlugin() + } + }, onClickLoadWeb = { showConfirmDialog = ShowConfirm(R.string.overwrite_local_settings) { @@ -306,9 +332,6 @@ fun HomeSettingsPage( addRow(false) { viewModel.resetToDefault() } } }, - onClickViewNextUp = { - showRemovedNextUpDialog = true - }, modifier = destModifier, ) } @@ -375,6 +398,29 @@ fun HomeSettingsPage( ) } } + if (showSaveDialog) { + SaveHomeSettingsDialog( + onDismissRequest = { showSaveDialog = false }, + onSaveLocal = { + scope.launch { + viewModel.saveToLocal().join() + viewModel.navigationManager.goBack() + } + }, + onSaveServer = { + scope.launch { + viewModel.saveToRemote().join() + viewModel.navigationManager.goBack() + } + }, + onDiscard = { + scope.launch { + viewModel.discardChanges().join() + viewModel.navigationManager.goBack() + } + }, + ) + } } data class ShowConfirm( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index dc527ed4..72289463 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -49,6 +49,11 @@ enum class MoveDirection { DOWN, } +private const val POS_ADD_ROW = 0 +private const val POS_SOURCE = POS_ADD_ROW + 1 +private const val POS_SETTINGS = POS_SOURCE + 1 +private const val POS_PRESETS = POS_SETTINGS + 1 + @Composable fun HomeSettingsRowList( state: HomePageSettingsState, @@ -58,13 +63,14 @@ fun HomeSettingsRowList( onClickPresets: () -> Unit, onClickMove: (MoveDirection, Int) -> Unit, onClickDelete: (Int) -> Unit, + onClickSourceSettings: () -> Unit, modifier: Modifier, ) { val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() - val itemsBeforeRows = 4 + val itemsBeforeRows = POS_PRESETS + 1 // +1 for divider val focusRequesters = remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } } @@ -80,7 +86,7 @@ fun HomeSettingsRowList( modifier = modifier .fillMaxHeight() - .focusRestorer(focusRequesters[0]), + .focusRestorer(focusRequesters[POS_ADD_ROW]), ) { item { HomeSettingsListItem( @@ -93,10 +99,30 @@ fun HomeSettingsRowList( ) }, onClick = { - position = 0 + position = POS_ADD_ROW onClickAdd.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[0]), + modifier = Modifier.focusRequester(focusRequesters[POS_ADD_ROW]), + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = "Source", + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + supportingContent = { + Text(text = stringResource(state.source.stringResId)) + }, + onClick = { + position = POS_SOURCE + onClickSourceSettings.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[POS_SOURCE]), ) } item { @@ -110,10 +136,10 @@ fun HomeSettingsRowList( ) }, onClick = { - position = 1 + position = POS_SETTINGS onClickSettings.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[1]), + modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]), ) } item { @@ -132,10 +158,10 @@ fun HomeSettingsRowList( ) }, onClick = { - position = 2 + position = POS_PRESETS onClickPresets.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[2]), + modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]), ) } item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt new file mode 100644 index 00000000..8139b651 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.HomePageSettingsSource +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun HomeSettingsSourcePage( + source: HomePageSettingsSource, + onClickSave: () -> Unit, + onClickLoad: () -> Unit, + onClickLoadWeb: () -> Unit, + onClickReset: () -> Unit, + serverPluginActive: Boolean, + onClickLoadPlugin: () -> Unit, + modifier: Modifier = Modifier, +) { + val firstFocus: FocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + Text( + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_server), + leadingContent = { + Text( + text = stringResource(R.string.fa_cloud_arrow_down), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoad, + modifier = Modifier, + ) + } + if (serverPluginActive) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_server_plugin), + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoadPlugin, + modifier = Modifier, + ) + } + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_web_client), + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoadWeb, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.reset), + leadingContent = { + Text( + text = stringResource(R.string.fa_arrows_rotate), + fontFamily = FontAwesome, + ) + }, + onClick = onClickReset, + modifier = Modifier, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index e3eafcbb..1082a23f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -24,16 +24,18 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.HomeDataService import com.github.damontecres.wholphin.services.HomePageResolvedSettings +import com.github.damontecres.wholphin.services.HomePageSettingsSource import com.github.damontecres.wholphin.services.HomeRowConfigDisplay import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.ServerPluginApi import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.getRecentlyAddedTitle -import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope -import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.services.viewOptionsForCollectionType import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.launchDefault @@ -48,13 +50,16 @@ import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -76,37 +81,55 @@ class HomeSettingsViewModel @param:ApplicationContext private val context: Context, private val api: ApiClient, private val homeSettingsService: HomeSettingsService, + private val homeDataService: HomeDataService, private val serverRepository: ServerRepository, private val userPreferencesService: UserPreferencesService, private val navDrawerService: NavDrawerService, private val backdropService: BackdropService, private val seerrServerRepository: SeerrServerRepository, + private val serverPluginApi: ServerPluginApi, val preferencesDataStore: DataStore, - @param:IoCoroutineScope private val ioScope: CoroutineScope, + val navigationManager: NavigationManager, ) : ViewModel() { private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) val state: StateFlow = _state + val serverPluginActive get() = serverRepository.serverPluginInstalled + val currentUser + get() = + serverRepository.currentUserFlow.filterNotNull().stateIn( + viewModelScope, + SharingStarted.Eagerly, + serverRepository.currentUser!!, + ) + val settingsChanged = + state + .map { + HomePageSettings( + rows = it.rows.map { it.config }, + version = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, + ) != originalSettings + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + private var idCounter by Delegates.notNull() val discoverEnabled = seerrServerRepository.active - private var originalLocalSettings: HomePageSettings? = null - private var originalRemoteSettings: HomePageSettings? = null + private var originalSettings: HomePageSettings? = null init { - addCloseable { saveToLocal() } viewModelScope.launchIO { + val user = serverRepository.currentUser ?: return@launchIO val userDto = serverRepository.currentUserDto ?: return@launchIO val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val currentSettings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } - originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id) - originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id) + originalSettings = currentSettings.asHomePageSettings() Timber.v("currentSettings=%s", currentSettings) idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { it.copy( + source = currentSettings.source, libraries = libraries, rows = currentSettings.rows, ) @@ -134,7 +157,7 @@ class HomeSettingsViewModel viewModelScope.async(WholphinDispatchers.IO) { semaphore.withPermit { try { - homeSettingsService.fetchDataForRow( + homeDataService.fetchDataForRow( row = row.config, scope = viewModelScope, prefs = prefs, @@ -496,7 +519,7 @@ class HomeSettingsViewModel } } - fun saveToRemote() { + fun saveToRemote(): Job = viewModelScope.launchIO { serverRepository.currentUser?.let { user -> Timber.d("Saving home settings to remote") @@ -506,6 +529,10 @@ class HomeSettingsViewModel try { Timber.d("saveToRemote") homeSettingsService.saveToServer(user.id, settings) + homeSettingsService.updateCurrent( + HomePageSettingsSource.SERVER_PROFILE, + settings, + ) showSaveToast() } catch (ex: Exception) { Timber.e(ex) @@ -513,7 +540,6 @@ class HomeSettingsViewModel } } } - } fun loadFromRemote() { viewModelScope.launchIO { @@ -530,7 +556,10 @@ class HomeSettingsViewModel } idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = newRows) + it.copy( + source = HomePageSettingsSource.SERVER_PROFILE, + rows = newRows, + ) } } else { Timber.v("No remote settings") @@ -560,7 +589,10 @@ class HomeSettingsViewModel Timber.v("Got web settings") idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = result.rows) + it.copy( + source = HomePageSettingsSource.LOCAL, + rows = result.rows, + ) } } else { Timber.v("No web settings") @@ -575,34 +607,19 @@ class HomeSettingsViewModel } } - fun saveToLocal() { - // This uses injected ioScope so that it will still run when the page is closing - ioScope.launchIO { + fun saveToLocal(): Job = + viewModelScope.launchIO { serverRepository.currentUser?.let { user -> val rows = state.value.rows.map { it.config } val settings = HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) try { Timber.d("saveToLocal") - // Only save if there are changes based on original source - val shouldSave = - if (originalLocalSettings != null) { - originalLocalSettings != settings - } else if (originalRemoteSettings != null) { - originalRemoteSettings != settings - } else { - true - } - if (shouldSave) { - homeSettingsService.saveToLocal(user.id, settings) - homeSettingsService.updateCurrent(user.id, settings) - showSaveToast() - } else { - Timber.d("No changes") - } - } catch (ex: UnsupportedHomeSettingsVersionException) { - Timber.w(ex, "Overwriting local settings") homeSettingsService.saveToLocal(user.id, settings) + homeSettingsService.updateCurrent( + HomePageSettingsSource.LOCAL, + settings, + ) showSaveToast() } catch (ex: Exception) { Timber.e(ex) @@ -610,17 +627,11 @@ class HomeSettingsViewModel } } } - } private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) { _state.update { update.invoke(it) } - serverRepository.currentUser?.id?.let { userId -> - homeSettingsService.currentSettings.update { - HomePageResolvedSettings(userId, state.value.rows) - } - } } fun resizeCards(relative: Int) { @@ -649,12 +660,32 @@ class HomeSettingsViewModel fun resetToDefault() = viewModelScope.launchIO { - val userId = serverRepository.currentUser?.id ?: return@launchIO + val user = serverRepository.currentUser ?: return@launchIO _state.update { it.copy(loading = LoadingState.Loading) } - val result = homeSettingsService.createDefault(userId) + val result = + if (serverPluginActive.value) { + try { + val settings = + homeSettingsService.fetchSettingsFor( + user.id, + HomePageSettingsSource.PLUGIN, + ) + settings + } catch (ex: Exception) { + Timber.e(ex, "Plugin is active, but error fetching home settings") + null + } + } else { + null + } ?: homeSettingsService.createDefault(user.id) + idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = result.rows, rowData = emptyList()) + it.copy( + source = result.source, + rows = result.rows, + rowData = emptyList(), + ) } fetchRowData() } @@ -746,6 +777,10 @@ class HomeSettingsViewModel is HomeRowConfig.TvChannels -> { it.config.updateViewOptions(preset.liveTv) } + + is HomeRowConfig.CustomEndpoint -> { + it.config + } } it.copy(config = newConfig) } @@ -767,6 +802,43 @@ class HomeSettingsViewModel } } + fun loadFromRemotePlugin() { + viewModelScope.launchIO { + Timber.d("Loading home settings from server plugin") + try { + // TODO should this remove local settings? + // TODO how to set up priorities + _state.update { it.copy(loading = LoadingState.Loading) } + val result = serverPluginApi.fetchHomePageSettings() + if (result != null) { + Timber.v("Got remote settings") + val newRows = + result.rows.mapIndexed { index, config -> + homeSettingsService.resolve(index, config) + } + idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 + _state.update { + it.copy( + source = HomePageSettingsSource.PLUGIN, + rows = newRows, + ) + } + } else { + Timber.v("No server plugin settings") + showToast(context, "No server plugin settings found") + } + fetchRowData() + } catch (ex: UnsupportedHomeSettingsVersionException) { + // TODO + Timber.w(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + fun onConfigAction( row: HomeRowConfigDisplay, action: HomeRowConfigAction, @@ -861,10 +933,13 @@ class HomeSettingsViewModel fetchRowData() } } + + fun discardChanges(): Job = Job().apply { complete() } } data class HomePageSettingsState( val loading: LoadingState, + val source: HomePageSettingsSource, val rows: List, val rowData: List, val libraries: List, @@ -873,6 +948,7 @@ data class HomePageSettingsState( val EMPTY = HomePageSettingsState( LoadingState.Pending, + HomePageSettingsSource.UNSET, emptyList(), emptyList(), emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt new file mode 100644 index 00000000..c7531b6d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup + +@Composable +fun SaveHomeSettingsDialog( + onDismissRequest: () -> Unit, + onSaveLocal: () -> Unit, + onSaveServer: () -> Unit, + onDiscard: () -> Unit, +) { + val resources = LocalResources.current + val params = + remember(resources) { + DialogParams( + fromLongClick = false, + title = resources.getString(R.string.save), + items = + listOf( + DialogItem( + text = R.string.save_to_local, + iconStringRes = R.string.fa_cloud_arrow_down, + onClick = onSaveLocal, + ), + DialogItem( + text = R.string.save_to_server, + iconStringRes = R.string.fa_cloud_arrow_up, + onClick = onSaveServer, + ), + DialogItem( + text = "Discard", + icon = Icons.Default.Delete, + iconColor = Color.Red, + onClick = onDiscard, + ), + ), + ) + } + DialogPopup( + params = params, + onDismissRequest = onDismissRequest, + elevation = 3.dp, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index a65c97e8..110cb637 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -134,7 +134,7 @@ sealed class Destination( @Serializable data class Slideshow( - val parentId: UUID, + val parentId: UUID?, val index: Int, val filter: CollectionFolderFilter, val sortAndDirection: SortAndDirection, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 17906fe2..c26cb891 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -80,6 +80,7 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.TimeDisplay +import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption @@ -573,6 +574,7 @@ fun NavigationDrawerScope.ProfileIcon( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { + val focused by interactionSource.collectIsFocusedAsState() NavigationDrawerItem( modifier = modifier, selected = false, @@ -595,7 +597,7 @@ fun NavigationDrawerScope.ProfileIcon( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = user.name ?: user.id.toString(), maxLines = 1, ) @@ -639,7 +641,7 @@ fun NavigationDrawerScope.IconNavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = text, maxLines = 1, ) @@ -733,7 +735,7 @@ fun NavigationDrawerScope.NavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = library.name(context), maxLines = 1, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index 871264c2..b17036b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaylistCreationResult @@ -158,7 +159,11 @@ class PlayExternalViewModel is PlaylistItem.Media -> playlistItem.item } val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig) - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.SUBTITLE, + ) if (mediaSource == null) { Timber.w("Media source is null") return@launchDefault @@ -168,13 +173,14 @@ class PlayExternalViewModel val subtitleIndex = streamChoiceService .chooseSubtitleStream( + item = item.data, source = mediaSource, audioStream = null, - seriesId = item.data.seriesId, itemPlayback = playbackConfig, - plc = plc, + stc = subtitleStc, prefs = prefs, - )?.index + )?.stream + ?.index val externalSubtitles = mediaSource.mediaStreams ?.filter { it.isExternal } @@ -237,6 +243,8 @@ class PlayExternalViewModel } // VLC + // A zero position alone does not override VLC's saved resume position. + putExtra("from_start", positionMs == 0L) if (subtitleUrls.isNotEmpty()) { putExtra("subtitles_location", subtitleUrls.first().toString()) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 491498cb..3165246f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -42,8 +42,10 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.formatMaxBitrate import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem @@ -60,6 +62,7 @@ enum class PlaybackDialogType { PLAYBACK_SPEED, VIDEO_SCALE, SUBTITLE_DELAY, + BITRATE, } data class PlaybackSettings( @@ -73,6 +76,7 @@ data class PlaybackSettings( val subtitleDelay: Duration, val hasSubtitleDownloadPermission: Boolean, val playbackSpeedEnabled: Boolean, + val maxBitrate: Long, ) /** @@ -162,6 +166,13 @@ fun PlaybackDialog( ), ) } + add( + BottomDialogItem( + data = PlaybackDialogType.BITRATE, + headline = stringResource(R.string.max_bitrate), + supporting = remember(settings.maxBitrate) { formatMaxBitrate(settings.maxBitrate) }, + ), + ) add( BottomDialogItem( data = PlaybackDialogType.DEBUG, @@ -271,6 +282,29 @@ fun PlaybackDialog( } } } + + PlaybackDialogType.BITRATE -> { + val choices = + AppPreference.bitrateValues.map { bitrate -> + BottomDialogItem( + data = bitrate, + headline = formatMaxBitrate(bitrate), + supporting = null, + ) + } + BottomDialog( + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.maxBitrate }, + onDismissRequest = { + onControllerInteraction.invoke() + onDismissRequest.invoke() + }, + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.ChangeBitrate(choice.data)) + }, + gravity = leftGravity, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 483083f2..86d64651 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -322,6 +322,10 @@ fun PlaybackPageContent( player.seekToPrevious() } } + + is PlaybackAction.ChangeBitrate -> { + viewModel.changeMaxBitrate(it.maxBitrate) + } } } @@ -752,6 +756,7 @@ fun PlaybackPageContent( // TODO Passing through audio prevents changing playback speed // See https://github.com/damontecres/Wholphin/issues/164 playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null, + maxBitrate = state.maxBitrate, ), onDismissRequest = { playbackDialog = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt index b42b7151..cca3a0d0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt @@ -24,6 +24,7 @@ data class PlaybackState( val nextUp: BaseItem? = null, val playlistIndex: Int = 0, val playlist: Playlist = Playlist(emptyList()), + val maxBitrate: Long = -1L, ) { val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size val hasPrevious: Boolean get() = playlistIndex > 0 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 012038a8..24bd8ee9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -5,6 +5,7 @@ import android.media.MediaCodecList import android.os.Build import android.widget.Toast import androidx.annotation.OptIn +import androidx.annotation.VisibleForTesting import androidx.compose.ui.unit.Density import androidx.core.net.toUri import androidx.lifecycle.ViewModel @@ -34,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AppPreference @@ -198,7 +200,7 @@ class PlaybackViewModel val currentUserDto = serverRepository.currentUserDtoFlow - // Exposed for testing + @VisibleForTesting val initJob: Job init { @@ -290,8 +292,16 @@ class PlaybackViewModel */ private suspend fun init() { musicService.stop() - _state.update { it.copy(nextUp = null) } this.preferences = userPreferencesService.getCurrent() + val maxBitrate = + preferences.appPreferences.playbackPreferences.maxBitrate + .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE + _state.update { + it.copy( + nextUp = null, + maxBitrate = maxBitrate, + ) + } if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { addCloseable { refreshRateService.resetRefreshRate() } } @@ -489,7 +499,16 @@ class PlaybackViewModel null } - val plc = streamChoiceService.getPlaybackLanguageChoice(base) + val audioStc = + streamChoiceService.getSeriesTrackChoices( + base, + SeriesTrackChoiceType.AUDIO, + ) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + base, + SeriesTrackChoiceType.AUDIO, + ) if (mediaSource == null && !isLiveTv) { showToast( @@ -518,30 +537,31 @@ class PlaybackViewModel val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty() val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty() - val audioStream = + val audioResult = mediaSource?.let { streamChoiceService .chooseAudioStream( source = mediaSource, - seriesId = base.seriesId, + item = base, itemPlayback = itemPlayback, - plc = plc, + stc = audioStc, prefs = preferences, ) } - val audioIndex = audioStream?.index + val audioIndex = audioResult?.stream?.index val subtitleIndex = mediaSource?.let { streamChoiceService .chooseSubtitleStream( + item = base, source = mediaSource, - audioStream = audioStream, - seriesId = base.seriesId, + audioStream = audioResult?.stream, itemPlayback = itemPlayback, - plc = plc, + stc = subtitleStc, prefs = preferences, - )?.index + )?.stream + ?.index } Timber.d( "Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s", @@ -616,6 +636,7 @@ class PlaybackViewModel positionMs: Long = 0, enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding, + maxBitrate: Long = this.state.value.maxBitrate, ): Unit = withContext(WholphinDispatchers.IO) { val itemId = item.id @@ -626,7 +647,8 @@ class PlaybackViewModel state.value.currentPlayback?.let { currentPlayback -> if (currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && - enableDirectPlay + enableDirectPlay && + state.value.maxBitrate == maxBitrate ) { val wasSuccessful = changeStreamsDirectPlay( @@ -639,18 +661,17 @@ class PlaybackViewModel } Timber.i( - "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s", + "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s", itemId, audioIndex, subtitleIndex, enableDirectPlay, enableDirectStream, positionMs, + maxBitrate, ) + _state.update { it.copy(maxBitrate = maxBitrate) } - val maxBitrate = - preferences.appPreferences.playbackPreferences.maxBitrate - .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE val response by api.mediaInfoApi .getPostedPlaybackInfo( @@ -696,6 +717,7 @@ class PlaybackViewModel api.videosApi.getVideoStreamUrl( itemId = itemId, mediaSourceId = source.id, + liveStreamId = source.liveStreamId, static = true, tag = source.eTag, playSessionId = response.playSessionId, @@ -955,7 +977,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = currentPlayback.mediaSourceInfo, audioStreamIndex = index, - seriesId = currentItem.item.data.seriesId, + item = currentItem.item.data, subtitleIndex = currentPlayback.subtitleIndex, prefs = preferences, ) @@ -991,7 +1013,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = currentPlayback.mediaSourceInfo, audioStreamIndex = currentPlayback.audioIndex, - seriesId = currentItem.item.data.seriesId, + item = currentItem.item.data, subtitleIndex = index, prefs = preferences, ) @@ -1009,6 +1031,26 @@ class PlaybackViewModel } } + fun changeMaxBitrate(newMaxBitrate: Long) { + viewModelScope.launchIO { + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null) { + Timber.d("Changing bitrate to %s", newMaxBitrate) + changeStreams( + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = currentPlayback.audioIndex, + subtitleIndex = currentPlayback.subtitleIndex, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, + maxBitrate = newMaxBitrate, + ) + } else { + Timber.w("Trying to change bitrate, but currentPlayback is null") + } + } + } + internal suspend fun saveTrackSelection( trackIndex: Int, type: MediaStreamType, @@ -1110,7 +1152,7 @@ class PlaybackViewModel } // Variables for tracking segment state - // Exposed for testing + @VisibleForTesting internal var segmentJob: Job? = null private val autoSkippedSegments = mutableSetOf() private val outroShownSegments = mutableSetOf() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index ba77d8b7..45fcdd4c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape @@ -122,6 +123,10 @@ sealed interface PlaybackAction { data object Previous : PlaybackAction data object Next : PlaybackAction + + data class ChangeBitrate( + val maxBitrate: Long, + ) : PlaybackAction } /** @@ -561,12 +566,19 @@ fun BottomDialog( gravity: Int, currentChoice: BottomDialogItem? = null, ) { + val initialIndex = + remember(choices, currentChoice) { choices.indexOfFirstOrNull { it == currentChoice } } ?: 0 + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialIndex, + ) val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + val bringIntoViewRequesters = + remember(choices.size) { List(choices.size) { BringIntoViewRequester() } } if (currentChoice != null) { LaunchedEffect(Unit) { - choices.indexOfFirstOrNull { it == currentChoice }?.let { - focusRequesters.getOrNull(it)?.tryRequestFocus() - } + bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView() + focusRequesters.getOrNull(initialIndex)?.tryRequestFocus() } } // TODO enforcing a width ends up ignore the gravity @@ -591,6 +603,7 @@ fun BottomDialog( ), ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() @@ -624,7 +637,10 @@ fun BottomDialog( } }, interactionSource = interactionSource, - modifier = Modifier.focusRequester(focusRequesters[index]), + modifier = + Modifier + .bringIntoViewRequester(bringIntoViewRequesters[index]) + .focusRequester(focusRequesters[index]), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index 0a843dcb..81f4833b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -1,6 +1,13 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.preferences.AppPreference import kotlinx.serialization.Serializable @@ -38,9 +45,53 @@ enum class PreferenceScreenOption { SCREENSAVER, SKIP_SEGMENTS, EXPERIMENTAL, + HDR_FORMATS, ; companion object { fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC } } + +@Composable +fun rememberSeerrDialogBody( + user: SeerrUser, + server: SeerrServer, + pluginProvided: Boolean, + ex: Exception? = null, +): String { + val username = + when (user.authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> user.username ?: stringResource(R.string.unknown) + + SeerrAuthMethod.API_KEY -> stringResource(R.string.api_key) + } + val method = + when (user.authMethod) { + SeerrAuthMethod.LOCAL -> R.string.seerr_local_user + SeerrAuthMethod.JELLYFIN -> R.string.seerr_jellyfin_user + SeerrAuthMethod.API_KEY -> 0 + }.let { + if (it != 0) " (${stringResource(it)})" else "" + } + + val body = + """ + ${stringResource(R.string.url)}: ${server.url} + ${stringResource(R.string.username)}: $username$method + """.trimIndent() + + if (pluginProvided) { + "\n\n${stringResource(R.string.managed_by_server)}" + } else { + "" + } + + if (ex != null) { + "\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}" + } else { + "" + } + + return remember { body } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index a21405c2..0e388213 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -36,10 +36,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation @@ -51,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.ExperimentalPreference +import com.github.damontecres.wholphin.preferences.HdrFormatPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ScreensaverPreference @@ -74,9 +77,11 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings +import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.setup.ReleaseNotes import com.github.damontecres.wholphin.ui.setup.UpdateViewModel -import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -98,6 +103,7 @@ fun PreferencesContent( onFocus: (Int, Int) -> Unit = { _, _ -> }, ) { val context = LocalContext.current + val resources = LocalResources.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } val firstFocusRequester = remember { FocusRequester() } @@ -110,6 +116,11 @@ fun PreferencesContent( var showPinFlow by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) } val players by viewModel.externalPlayers.collectAsState() + val serverSupported by viewModel.serverSupported.collectAsState() + val serverVersionMessage = + remember(resources, serverSupported) { + resources.serverSupportedMessage(serverSupported.second, serverSupported.first) + } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrConnection by viewModel.seerrConnection.collectAsState() @@ -155,6 +166,7 @@ fun PreferencesContent( PreferenceScreenOption.SCREENSAVER -> screensaverPreferences PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences + PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences } val screenTitle = when (preferenceScreenOption) { @@ -165,6 +177,7 @@ fun PreferencesContent( PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings + PreferenceScreenOption.HDR_FORMATS -> R.string.hdr_formats } var visible by remember { mutableStateOf(false) } @@ -224,6 +237,19 @@ fun PreferencesContent( contentPadding = PaddingValues(16.dp), modifier = Modifier.fillMaxSize(), ) { + serverVersionMessage?.let { message -> + item { + ListItem( + selected = false, + enabled = true, + onClick = {}, + headlineContent = { + PreferenceTitle(message, color = MaterialTheme.colorScheme.error) + }, + modifier = Modifier, + ) + } + } if (showUpdate) { item { val interactionSource = remember { MutableInteractionSource() } @@ -259,6 +285,7 @@ fun PreferencesContent( ) } } + prefList.forEachIndexed { groupIndex, group -> item { Text( @@ -475,10 +502,11 @@ fun PreferencesContent( title = stringResource(pref.title), onClick = { seerrDialogMode = - when (val conn = seerrConnection) { + when (val conn = seerrConnection.connection) { is SeerrConnectionStatus.Error -> { SeerrDialogMode.Error( - conn.server.url, + conn.server, + conn.user, conn.ex, ) } @@ -489,22 +517,20 @@ fun PreferencesContent( is SeerrConnectionStatus.Success -> { SeerrDialogMode.Remove( - conn.current.server.url, + conn.current, ) } } }, modifier = focusModifier, summary = - when (seerrConnection) { + when (seerrConnection.connection) { is SeerrConnectionStatus.Error -> { stringResource(R.string.voice_error_server) } SeerrConnectionStatus.NotConfigured -> { - stringResource( - R.string.add_server, - ) + stringResource(R.string.add_server) } is SeerrConnectionStatus.Success -> { @@ -699,64 +725,15 @@ fun PreferencesContent( ) } } - when (val mode = seerrDialogMode) { - is SeerrDialogMode.Remove -> { - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = mode.serverUrl, - onCancel = { seerrDialogMode = SeerrDialogMode.None }, - onConfirm = { - seerrVm.removeServer() - seerrDialogMode = SeerrDialogMode.None - }, - ) - } - - SeerrDialogMode.Add -> { - val currentUser by seerrVm.currentUser.collectAsState(null) - val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) - val serverAddedMessage = stringResource(R.string.seerr_server_added) - LaunchedEffect(status) { - if (status == LoadingState.Success) { - Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() - seerrDialogMode = SeerrDialogMode.None - } - } - AddSeerServerDialog( - currentUsername = currentUser?.name, - status = status, - onSubmit = seerrVm::submitServer, - onResetStatus = seerrVm::resetStatus, - onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, - ) - } - - is SeerrDialogMode.Error -> { - val errorStr = stringResource(R.string.voice_error_server) - val body = - remember(mode) { - """ - ${mode.serverUrl} - - $errorStr: ${mode.ex.localizedMessage} - """.trimIndent() - } - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = body, - onCancel = { seerrDialogMode = SeerrDialogMode.None }, - onConfirm = { - seerrVm.removeServer() - seerrDialogMode = SeerrDialogMode.None - }, - bodyColor = MaterialTheme.colorScheme.error, - ) - } - - SeerrDialogMode.None -> {} - } } + SeerrDialog( + seerrDialogMode = seerrDialogMode, + seerrConnection = seerrConnection, + onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, + seerrVm = seerrVm, + ) + if (showQuickConnectDialog) { val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending) val successMessage = stringResource(R.string.quick_connect_success) @@ -838,6 +815,7 @@ fun PreferencesPage( PreferenceScreenOption.SCREENSAVER, PreferenceScreenOption.SKIP_SEGMENTS, PreferenceScreenOption.EXPERIMENTAL, + PreferenceScreenOption.HDR_FORMATS, -> { PreferencesContent( initialPreferences, @@ -857,18 +835,3 @@ data class CacheUsage( val imageMemoryMax: Long, val imageDiskUsed: Long, ) - -private sealed interface SeerrDialogMode { - data object None : SeerrDialogMode - - data object Add : SeerrDialogMode - - data class Remove( - val serverUrl: String, - ) : SeerrDialogMode - - data class Error( - val serverUrl: String, - val ex: Exception, - ) : SeerrDialogMode -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 200bac4e..2e0ce392 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppPreferences @@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState @@ -33,6 +35,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient @@ -76,6 +79,23 @@ class PreferencesViewModel val externalPlayers = MutableStateFlow>(emptyList()) + val serverSupported = + serverRepository.currentServerFlow + .map { + val serverVersion = it?.serverVersion + serverVersion to + when { + serverVersion == null -> ServerVersionSupported.NOT_SUPPORTED + serverVersion < WholphinApplication.minimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED + serverVersion < WholphinApplication.newMinimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE + else -> ServerVersionSupported.SUPPORTED + } + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null to ServerVersionSupported.SUPPORTED, + ) + init { viewModelScope.launchIO { val fakeIntent = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 81cc7c2a..bd14c38c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -32,6 +32,7 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.CircularProgress import org.jellyfin.sdk.model.api.PublicSystemInfo @@ -177,7 +178,12 @@ fun ServerIconCard( Icon( imageVector = Icons.Default.Warning, contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message, - tint = MaterialTheme.colorScheme.errorContainer, + tint = + if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) { + AppColors.GoldenYellow + } else { + MaterialTheme.colorScheme.errorContainer + }, modifier = Modifier.size(cardSize * 0.3f), ) Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index c25fe776..dd972f9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -11,6 +11,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -66,11 +67,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import org.jellyfin.sdk.model.ServerVersion import timber.log.Timber @Composable @@ -101,6 +103,8 @@ private fun SwitchServerContentInternal( modifier: Modifier = Modifier, ) { val resources = LocalResources.current + val focusRequesters = + remember(state.servers.size) { List(state.servers.size + 1) { FocusRequester() } } var showAddServer by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(null) } var focusedIndex by rememberInt(0) @@ -144,10 +148,13 @@ private fun SwitchServerContentInternal( val firstServerFocus = remember { FocusRequester() } LaunchedEffect(state.loading) { val state = state - if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) { - firstServerFocus.tryRequestFocus() - } else if (state.loading == LoadingState.Pending) { - firstServerFocus.tryRequestFocus() + when (state.loading) { + is LoadingState.Error, + LoadingState.Loading, + LoadingState.Pending, + -> Unit + + LoadingState.Success -> focusRequesters.firstOrNull()?.tryRequestFocus() } } LazyRow( @@ -188,10 +195,7 @@ private fun SwitchServerContentInternal( Modifier .onFocusChanged { if (it.isFocused) focusedIndex = index - }.ifElse( - index == 0, - Modifier.focusRequester(firstServerFocus), - ), + }.focusRequester(focusRequesters[index]), ) } // Add Server card - always rightmost @@ -202,10 +206,7 @@ private fun SwitchServerContentInternal( Modifier .onFocusChanged { if (it.isFocused) focusedIndex = -1 - }.ifElse( - state.servers.isEmpty(), - Modifier.focusRequester(firstServerFocus), - ), + }.focusRequester(focusRequesters.last()), ) } } @@ -235,20 +236,7 @@ private fun SwitchServerContentInternal( .fillMaxWidth() .align(Alignment.BottomCenter), ) { message -> - - Text( - text = message ?: "", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) - .align(Alignment.BottomCenter), - ) + SwitchErrorMessage(message ?: "") } // Delete server dialog @@ -327,12 +315,12 @@ private fun AddServerDialog( } } - val firstDiscoveredServerFocusRequester = remember { FocusRequester() } + val buttonFocusRequester = remember { FocusRequester() } // Default focus to first discovered server if available - LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) { - if (!showEnterAddress && state.discoveredServers.isNotEmpty()) { - firstDiscoveredServerFocusRequester.tryRequestFocus() + LaunchedEffect(showEnterAddress) { + if (!showEnterAddress) { + buttonFocusRequester.tryRequestFocus() } } @@ -379,12 +367,8 @@ private fun AddServerDialog( key = { state.discoveredServers[it].url }, ) { index -> val server = state.discoveredServers[index] - val focusRequester = - if (index == 0) { - firstDiscoveredServerFocusRequester - } else { - remember { FocusRequester() } - } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { if (index == 0) focusRequester.tryRequestFocus() } ListItem( enabled = true, @@ -491,21 +475,38 @@ fun serverErrorMessage( server: ServerState?, resources: Resources, ): String? = - when { - server?.status is ServerConnectionStatus.Error -> { - server.status.message - } - - server?.status is ServerConnectionStatus.Success && - server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { - resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}" - } - - server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { - resources.getString(R.string.server_version_not_supported) - } - - else -> { - null - } + if (server == null || server.status == ServerConnectionStatus.Pending) { + null + } else if (server.status is ServerConnectionStatus.Success) { + resources.serverSupportedMessage( + server.versionSupported, + server.status.systemInfo.version + ?.let { ServerVersion.fromString(it) }, + ) + } else if (server.status is ServerConnectionStatus.Error) { + server.status.message + } else { + resources.serverSupportedMessage( + server.versionSupported, + null, + ) } + +@Composable +fun BoxScope.SwitchErrorMessage(message: String?) { + message?.let { + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) + .align(Alignment.BottomCenter), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index 53190665..00beccb3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -97,6 +97,8 @@ class SwitchServerViewModel result.systemInfo.version?.let { ServerVersion.fromString(it) } if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) { ServerVersionSupported.NOT_SUPPORTED + } else if (serverVersion < WholphinApplication.newMinimumServerVersion) { + ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE } else { ServerVersionSupported.SUPPORTED } @@ -307,4 +309,5 @@ enum class ServerVersionSupported { SUPPORTED, NOT_SUPPORTED, UNKNOWN, + NOT_SUPPORTED_IN_FUTURE, } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 86cf718d..cfe70890 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -50,6 +49,7 @@ import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -183,37 +183,15 @@ fun SwitchUserContent( modifier = Modifier.fillMaxWidth(), ) } - when (state.serverVersionSupported) { - ServerVersionSupported.SUPPORTED -> {} - - ServerVersionSupported.NOT_SUPPORTED, - ServerVersionSupported.UNKNOWN, - -> { - val resources = LocalResources.current - val message = - remember(resources) { - if (state.serverVersion.isNotNullOrBlank()) { - resources.getString(R.string.server_version_not_supported) + ": ${state.serverVersion}" - } else { - resources.getString(R.string.server_version_not_supported) + ": " + - resources.getString(R.string.unknown) - } - } - Text( - text = message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) - .align(Alignment.BottomCenter), + val resources = LocalResources.current + val message = + remember(resources, state.serverVersionSupported) { + resources.serverSupportedMessage( + state.serverVersionSupported, + state.serverVersion, ) } - } + SwitchErrorMessage(message) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index e1a6dd96..b11b0b19 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -300,16 +300,18 @@ class SwitchUserViewModel knownUsers + publicUsers } - private suspend fun checkServerVersion(): Pair { + private suspend fun checkServerVersion(): Pair { val api = jellyfin.createApi(server.url) val systemInfo by api.systemApi.getPublicSystemInfo() val serverVersion = systemInfo.version?.let { ServerVersion.fromString(it) } return if (serverVersion == null) { - systemInfo.version to ServerVersionSupported.UNKNOWN + serverVersion to ServerVersionSupported.UNKNOWN } else if (serverVersion < WholphinApplication.minimumServerVersion) { - systemInfo.version to ServerVersionSupported.NOT_SUPPORTED + serverVersion to ServerVersionSupported.NOT_SUPPORTED + } else if (serverVersion < WholphinApplication.newMinimumServerVersion) { + serverVersion to ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE } else { - systemInfo.version to ServerVersionSupported.SUPPORTED + serverVersion to ServerVersionSupported.SUPPORTED } } @@ -341,7 +343,7 @@ data class SwitchUserState( // LoadingState for while adding/switching users val switchUserState: LoadingState = LoadingState.Pending, val loginAttempts: Int = 0, - val serverVersion: String? = null, + val serverVersion: ServerVersion? = null, val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 59c9b2ae..5faccc05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.util.LoadingState @Composable fun AddSeerrServerApiKey( + initialUrl: String, onSubmit: (url: String, apiKey: String) -> Unit, status: LoadingState, modifier: Modifier = Modifier, @@ -55,12 +56,18 @@ fun AddSeerrServerApiKey( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf(seerrUrlPrefill()) } + var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } var apiKey by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + if (url.isBlank()) { + focusRequester.tryRequestFocus() + } else { + passwordFocusRequester.tryRequestFocus() + } + } Text( text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, @@ -153,6 +160,7 @@ fun AddSeerrServerApiKey( @Composable fun AddSeerrServerUsername( + initialUrl: String, onSubmit: (url: String, username: String, password: String) -> Unit, username: String, status: LoadingState, @@ -167,14 +175,22 @@ fun AddSeerrServerUsername( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf(seerrUrlPrefill()) } + var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } var username by remember { mutableStateOf(username) } var password by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val usernameFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + val focusRequesterToUse = + when { + url.isNotNullOrBlank() && username.isNotNullOrBlank() -> passwordFocusRequester + url.isNotNullOrBlank() -> usernameFocusRequester + else -> focusRequester + } + focusRequesterToUse.tryRequestFocus() + } Text( text = stringResource(R.string.username_or_password), style = MaterialTheme.typography.titleMedium, @@ -318,6 +334,7 @@ fun AddSeerrServerUsername( private fun AddSeerrServerUsernamePreview() { WholphinTheme { AddSeerrServerUsername( + initialUrl = "", onSubmit = { string: String, string1: String, string2: String -> }, username = "test", status = LoadingState.Pending, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index bb38aabb..5be93b83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -1,27 +1,161 @@ package com.github.damontecres.wholphin.ui.setup.seerr +import android.widget.Toast +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.services.CurrentSeerr +import com.github.damontecres.wholphin.services.SeerrState import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.ScrollableDialog +import com.github.damontecres.wholphin.ui.preferences.rememberSeerrDialogBody import com.github.damontecres.wholphin.util.LoadingState +@Composable +fun SeerrDialog( + seerrDialogMode: SeerrDialogMode, + seerrConnection: SeerrState, + onDismissRequest: () -> Unit, + seerrVm: SwitchSeerrViewModel = hiltViewModel(), +) { + val context = LocalContext.current + when (val mode = seerrDialogMode) { + is SeerrDialogMode.Remove -> { + val body = rememberSeerrDialogBody(mode.current.user, mode.current.server, seerrConnection.pluginProvided) + if (seerrConnection.pluginProvided) { + ScrollableDialog( + onDismissRequest = onDismissRequest, + ) { + item { + Text(body) + } + } + } else { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.removeServer() + onDismissRequest.invoke() + }, + elevation = 3.dp, + ) + } + } + + SeerrDialogMode.Add -> { + val currentUser by seerrVm.currentUser.collectAsState(null) + val state by seerrVm.state.collectAsState() + val serverAddedMessage = stringResource(R.string.seerr_server_added) + LaunchedEffect(state.serverConnectionStatus) { + if (state.serverConnectionStatus == LoadingState.Success) { + Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() + onDismissRequest.invoke() + } + } + when (state.loading) { + is LoadingState.Error, + LoadingState.Loading, + LoadingState.Pending, + -> { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + LoadingPage(Modifier.padding(16.dp)) + } + } + + LoadingState.Success -> { + AddSeerServerDialog( + initialUrl = state.initialUrl, + currentUsername = currentUser?.name, + status = state.serverConnectionStatus, + onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, + onDismissRequest = onDismissRequest, + ) + } + } + } + + is SeerrDialogMode.Error -> { + val body = rememberSeerrDialogBody(mode.user, mode.server, seerrConnection.pluginProvided, mode.ex) + if (seerrConnection.pluginProvided) { + ConfirmDialog( + title = stringResource(R.string.error), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.retryServerFromPlugin() + onDismissRequest.invoke() + }, + bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, + confirmStringRes = R.string.retry, + ) + } else { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.removeServer() + onDismissRequest.invoke() + }, + bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, + ) + } + } + + SeerrDialogMode.None -> {} + } +} + +sealed interface SeerrDialogMode { + data object None : SeerrDialogMode + + data object Add : SeerrDialogMode + + data class Remove( + val current: CurrentSeerr, + ) : SeerrDialogMode + + data class Error( + val server: SeerrServer, + val user: SeerrUser, + val ex: Exception, + ) : SeerrDialogMode +} + @Composable fun AddSeerServerDialog( currentUsername: String?, + initialUrl: String?, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onResetStatus: () -> Unit, @@ -42,6 +176,7 @@ fun AddSeerServerDialog( properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( + initialUrl = initialUrl ?: "", onSubmit = { url, username, password -> onSubmit.invoke(url, username, password, auth) }, @@ -57,6 +192,7 @@ fun AddSeerServerDialog( onDismissRequest = { authMethod = null }, ) { AddSeerrServerApiKey( + initialUrl = initialUrl ?: "", onSubmit = { url, apiKey -> onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 773f6c74..2fde3eae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -3,11 +3,13 @@ package com.github.damontecres.wholphin.ui.setup.seerr import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.ServerPluginApi import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState @@ -15,6 +17,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -28,11 +31,29 @@ class SwitchSeerrViewModel private val seerrServerRepository: SeerrServerRepository, private val seerrService: SeerrService, private val serverRepository: ServerRepository, + private val serverPluginApi: ServerPluginApi, ) : ViewModel() { val currentUser = serverRepository.currentUserFlow val currentSeerrServer = seerrServerRepository.currentServer - val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) + private val _state = MutableStateFlow(SwitchSeerrState()) + val state: StateFlow = _state + + init { + if (serverRepository.serverPluginInstalled.value) { + viewModelScope.launchIO { + try { + serverPluginApi.fetchSeerrSettings()?.serverUrl?.let { serverUrl -> + _state.update { it.copy(initialUrl = serverUrl) } + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching seerr settings") + } finally { + _state.update { it.copy(loading = LoadingState.Success) } + } + } + } + } fun submitServer( url: String, @@ -41,13 +62,21 @@ class SwitchSeerrViewModel authMethod: SeerrAuthMethod, ) { viewModelScope.launchIO { - serverConnectionStatus.update { LoadingState.Loading } + _state.update { it.copy(serverConnectionStatus = LoadingState.Loading) } val urls = try { createUrls(url) } catch (ex: IllegalArgumentException) { showToast(context, "Invalid URL") - serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } + _state.update { + it.copy( + serverConnectionStatus = + LoadingState.Error( + "Invalid URL", + ex, + ), + ) + } return@launchIO } Timber.v("Urls to try: %s", urls) @@ -98,7 +127,7 @@ class SwitchSeerrViewModel ) } } - serverConnectionStatus.update { LoadingState.Success } + _state.update { it.copy(serverConnectionStatus = LoadingState.Success) } } else { val message = results @@ -107,7 +136,7 @@ class SwitchSeerrViewModel "$url - ${s?.localizedMessage}" }.joinToString("\n") showToast(context, "Could not connect") - serverConnectionStatus.update { LoadingState.Error(message) } + _state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) } } } } @@ -121,11 +150,31 @@ class SwitchSeerrViewModel } } + fun retryServerFromPlugin() { + viewModelScope.launchIO { + val settings = serverPluginApi.fetchSeerrSettings() + if (settings != null) { + val result = seerrServerRepository.usePluginProvidedServer(settings) + if (!result) { + showToast(context, context.getString(R.string.voice_error_server)) + } + } else { + seerrServerRepository.clear() + } + } + } + fun resetStatus() { - serverConnectionStatus.update { LoadingState.Pending } + _state.update { it.copy(serverConnectionStatus = LoadingState.Pending) } } } +data class SwitchSeerrState( + val loading: LoadingState = LoadingState.Pending, + val serverConnectionStatus: LoadingState = LoadingState.Pending, + val initialUrl: String? = null, +) + fun createUrls(url: String): List { val urls = mutableListOf() if (url.startsWith("http://") || url.startsWith("https://")) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index e74ffdf2..8172b049 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -20,6 +20,8 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.PhotoItemFields import com.github.damontecres.wholphin.ui.launchIO @@ -141,13 +143,16 @@ class SlideshowViewModel ), ) serverRepository.currentUser?.let { user -> + val filter = - playbackEffectDao - .getPlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - )?.videoFilter + slideshowSettings.parentId?.let { + playbackEffectDao + .getPlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + )?.videoFilter + } if (filter != null) { Timber.v("Got filter for album %s", slideshowSettings.parentId) albumImageFilter = filter @@ -229,12 +234,21 @@ class SlideshowViewModel source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO } ChosenStreams( itemPlayback = null, - plc = null, + audioStc = emptyList(), + subtitleStc = emptyList(), itemId = image.id, source = source, videoStream = video, - audioStream = audio, - subtitleStream = null, + audioStream = + StreamChoiceResult( + audio, + StreamChoiceReason.Unknown, + ), + subtitleStream = + StreamChoiceResult( + null, + StreamChoiceReason.Unknown, + ), subtitlesDisabled = false, ) } @@ -430,7 +444,7 @@ class SlideshowViewModel fun saveGalleryFilter() { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { val vf = _imageFilter.value - if (vf != null) { + if (slideshowSettings.parentId != null) { albumImageFilter = vf serverRepository.currentUser?.let { user -> playbackEffectDao diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt new file mode 100644 index 00000000..31f4f6fa --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt @@ -0,0 +1,48 @@ +package com.github.damontecres.wholphin.ui.util + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.util.BlockingList +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import kotlin.random.Random + +/** + * Navigate to a random item in the given list + */ +suspend fun goToRandom( + navigateTo: (Destination) -> Unit, + parentId: String, + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + recursive: Boolean, + list: BlockingList, +) { + val randomIndex = Random.nextInt(list.size) + val random = list.getBlocking(randomIndex) + + Timber.d("Got random item #%s: %s", randomIndex, random?.id) + if (random != null) { + val destination = + if (random.type == BaseItemKind.PHOTO) { + Destination.Slideshow( + parentId = parentId.toUUIDOrNull(), + index = randomIndex, + filter = + CollectionFolderFilter( + filter = filter, + ), + sortAndDirection = sortAndDirection, + recursive = recursive, + startSlideshow = false, + ) + } else { + random.destination() + } + navigateTo(destination) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index 750ba711..2f98fccc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -22,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.GetProgramsDto +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest @@ -401,6 +402,25 @@ object GetArtistsHandler : RequestHandler { ): Response = api.artistsApi.getArtists((request)) } +@Serializable +object GetAlbumArtistsHandler : RequestHandler { + override fun prepare( + request: GetAlbumArtistsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetAlbumArtistsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetAlbumArtistsRequest, + ): Response = api.artistsApi.getAlbumArtists(request) +} + val requestSerializersModule = SerializersModule { polymorphic(Any::class) { @@ -416,6 +436,7 @@ val requestSerializersModule = subclass(GetStudiosRequest::class) subclass(GetRecordingsRequest::class) subclass(GetLiveTvChannelsRequest::class) - subclass(GetArtistsHandler::class) + subclass(GetArtistsRequest::class) + subclass(GetAlbumArtistsRequest::class) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index c334967f..d83ae269 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -1,7 +1,7 @@ package com.github.damontecres.wholphin.util import android.content.Context -import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.MostRecentServer import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.DeviceModule import com.google.auto.service.AutoService @@ -13,6 +13,7 @@ import org.acra.sender.ReportSender import org.acra.sender.ReportSenderException import org.acra.sender.ReportSenderFactory import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin @@ -39,26 +40,10 @@ class CrashReportSender : ReportSender { errorContent: CrashReportData, ) { Timber.v("Attempting to send crash report") - val prefs = ServerRepository.getServerSharedPreferences(context) - val serverUrl = prefs.getString(ServerRepository.SERVER_URL_KEY, null) - val accessToken = prefs.getString(ServerRepository.ACCESS_TOKEN_KEY, null) - if (serverUrl != null && accessToken != null) { - try { - val okHttpClient = - OkHttpClient - .Builder() - .build() - val okHttpFactory = OkHttpFactory(okHttpClient) - val api = - createJellyfin { - this.context = context - clientInfo = AppModule.clientInfo(context) - deviceInfo = DeviceModule.deviceInfo(context) - apiClientFactory = okHttpFactory - socketConnectionFactory = okHttpFactory - minimumServerVersion = Jellyfin.minimumVersion - }.createApi(baseUrl = serverUrl, accessToken = accessToken) - + try { + val recentServer = AppModule.mostRecentServerProvider(context).get() + val api = recentServer.createApi(context) + if (api != null) { val obj = JSONObject() for ((key, value) in errorContent.toMap()) { obj.put(key, value) @@ -75,13 +60,32 @@ class CrashReportSender : ReportSender { """.trimIndent() + jsonStr, ).content.fileName - Timber.i("Sent report to $serverUrl, filename=$filename") + Timber.i("Sent report to %s, filename=%s", api.baseUrl, filename) } - } catch (ex: Exception) { - throw ReportSenderException("Exception while sending crash report", ex) + } else { + throw ReportSenderException("Could not find valid server and/or credentials to use") } - } else { - throw ReportSenderException("Could not find valid server and/or credentials to use") + } catch (ex: Exception) { + throw ReportSenderException("Exception while sending crash report", ex) } } } + +private fun MostRecentServer.createApi(context: Context): ApiClient? = + (this as? MostRecentServer.ServerAndUser)?.let { + val okHttpClient = + OkHttpClient + .Builder() + .build() + val okHttpFactory = OkHttpFactory(okHttpClient) + val api = + createJellyfin { + this.context = context + clientInfo = AppModule.clientInfo(context) + deviceInfo = DeviceModule.deviceInfo(context) + apiClientFactory = okHttpFactory + socketConnectionFactory = okHttpFactory + minimumServerVersion = Jellyfin.minimumVersion + }.createApi(baseUrl = serverUrl, accessToken = accessToken) + api + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt new file mode 100644 index 00000000..b4f8e15f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt @@ -0,0 +1,71 @@ +@file:OptIn(UnstableApi::class) + +package com.github.damontecres.wholphin.util + +import androidx.annotation.OptIn +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import com.github.damontecres.wholphin.preferences.ExperimentalPreferences +import com.github.damontecres.wholphin.preferences.enabled +import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest + +/** + * The one place which decides whether Dolby Vision profile 7 is converted to profile 8.1 on this + * device, and how a converted track is described. + * + * Profile 7 is HDR10 compatible HEVC plus two extra NAL unit types: 62 carries the RPU metadata and + * 63 the enhancement layer. A decoder which only lists profile 8 takes such a stream but does not + * switch the display into Dolby Vision for it, so the picture comes out as the HDR10 base layer. + * Rewriting the RPU into its profile 8.1 form and dropping the enhancement layer produces the + * single layer stream that decoder does switch for. Nothing is re-encoded. + * + * The conversion has two halves which have to agree: + * - [DolbyVisionProfile7Filter] rewrites the access units on their way into the decoder; + * - [DolbyVisionProfile7ExtractorsFactory] rewrites the codec string of the track, because + * media3 picks the decoder by `Format.codecs` and a decoder which does not list profile 7 would + * otherwise never be chosen for it. + * + * Both are switched on by [isWanted], as is direct play of profile 7 in the device profile. + */ +object DolbyVisionProfile7Conversion { + /** + * True when both the user has enabled the setting and this device is one it helps: its Dolby + * Vision decoder lists profile 8 but not profile 7. A device which decodes profile 7 itself + * is left alone, since it can use the enhancement layer the conversion would throw away, and + * a device without a profile 8 decoder has nothing to handle the converted stream + */ + fun isWanted( + experimental: ExperimentalPreferences, + mediaTest: MediaCodecCapabilitiesTest, + ): Boolean = + experimental.enabled { convertDolbyVisionProfile7 } && + !mediaTest.supportsHevcDolbyVisionEL() && + mediaTest.supportsHevcDolbyVisionProfile8() + + /** Whether [format] is a Dolby Vision profile 7 video track, in either sample entry form. */ + fun isProfile7(format: Format): Boolean { + val codecs = format.codecs ?: return false + return MimeTypes.VIDEO_DOLBY_VISION == format.sampleMimeType && + (codecs.startsWith(CODECS_PREFIX_DVHE_07) || codecs.startsWith(CODECS_PREFIX_DVH1_07)) + } + + /** + * The [format] as the converted stream should be advertised: `dvhe.07.06` becomes + * `dvhe.08.06`, the level and the sample entry prefix stay. Encrypted tracks are left as they + * are: their samples reach the decoder through the secure path the filter does not touch, so + * advertising them as converted would be a lie. + */ + fun advertiseAsProfile8(format: Format): Format = + if (isProfile7(format) && format.drmInitData == null) { + format.buildUpon().setCodecs(toProfile8(format.codecs!!)).build() + } else { + format + } + + /** `dvhe.07.06` → `dvhe.08.06`. The caller checks [isProfile7] first. */ + fun toProfile8(codecs: String): String = codecs.substring(0, 5) + "08" + codecs.substring(7) + + private const val CODECS_PREFIX_DVHE_07 = "dvhe.07" + private const val CODECS_PREFIX_DVH1_07 = "dvh1.07" +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt new file mode 100644 index 00000000..632f46c3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt @@ -0,0 +1,79 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.ForwardingExtractor +import androidx.media3.extractor.ForwardingExtractorOutput +import androidx.media3.extractor.ForwardingExtractorsFactory +import androidx.media3.extractor.ForwardingTrackOutput +import androidx.media3.extractor.TrackOutput +import timber.log.Timber + +/** + * Advertises Dolby Vision profile 7 video tracks as profile 8.1, which is what + * [DolbyVisionProfile7Filter] turns their samples into before the decoder sees them. + * + * media3 chooses the decoder by `Format.codecs`, and a decoder which lists profile 8 but not + * profile 7 answers "not supported" to `dvhe.07`; with decoder fallback on, playback then lands on + * whichever decoder comes first in the list, with a warning that the format exceeds its + * capabilities. Rewriting the codec string at the extractor routes the track to the profile 8 + * decoder, and it also sets `MediaFormat.KEY_PROFILE` right, which media3 derives from the same + * string. + * + * Only the codec string changes. Sample data is not touched here: the two convenience overloads of + * [TrackOutput.sampleData] go straight to the delegate, which is exactly what is wanted. + */ +class DolbyVisionProfile7ExtractorsFactory( + delegate: ExtractorsFactory, +) : ForwardingExtractorsFactory(delegate) { + override fun createExtractors(): Array = wrap(super.createExtractors()) + + override fun createExtractors( + uri: Uri, + responseHeaders: Map>, + ): Array = wrap(super.createExtractors(uri, responseHeaders)) + + private fun wrap(extractors: Array): Array = + Array(extractors.size) { Profile8AdvertisingExtractor(extractors[it]) } +} + +private class Profile8AdvertisingExtractor( + delegate: Extractor, +) : ForwardingExtractor(delegate) { + override fun init(output: ExtractorOutput) { + super.init(Profile8AdvertisingExtractorOutput(output)) + } +} + +private class Profile8AdvertisingExtractorOutput( + output: ExtractorOutput, +) : ForwardingExtractorOutput(output) { + override fun track( + id: Int, + type: Int, + ): TrackOutput { + val trackOutput = super.track(id, type) + return if (type == C.TRACK_TYPE_VIDEO) Profile8AdvertisingTrackOutput(trackOutput) else trackOutput + } +} + +/** Rewrites the codec string of every profile 7 format it is handed; everything else passes unchanged. */ +internal class Profile8AdvertisingTrackOutput( + delegate: TrackOutput, +) : ForwardingTrackOutput(delegate) { + override fun format(format: Format) { + val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format) + if (advertised !== format) { + Timber.i("Advertising Dolby Vision track %s as %s for conversion", format.codecs, advertised.codecs) + } + super.format(advertised) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt new file mode 100644 index 00000000..7cf50622 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt @@ -0,0 +1,313 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi +import timber.log.Timber +import java.nio.Buffer +import java.nio.ByteBuffer + +/** + * Rewrites the Dolby Vision RPU of one access unit, in place. The production implementation is + * libdovi, as packaged by ExoplayerHdrUtils; tests substitute their own. + * + * Both methods take a direct buffer whose access unit starts at index 0 and is [size] bytes long. + * That is the shape the native code reads: it takes the buffer's address and capacity, not its + * position and limit. + */ +interface RpuRewriter { + /** + * The Dolby Vision profile of the RPU (NAL unit type 62) in the access unit, or 0 when there is + * none it can parse. + */ + fun profileOf( + frame: ByteBuffer, + size: Int, + ): Int + + /** + * Rewrites the profile 7 RPU in the access unit into its profile 8.1 form and returns the new + * size of the access unit. The bytes after the RPU move to stay contiguous, so the buffer's + * capacity must leave room past [size] for an RPU which comes out longer than it went in. + */ + fun convertToProfile8( + frame: ByteBuffer, + size: Int, + ): Int +} + +/** [RpuRewriter] on libdovi. Constructing it loads the native library, which throws a [LinkageError] on an ABI it does not ship for. */ +class LibDoviRpuRewriter : RpuRewriter { + private val libDovi = LibDovi() + + override fun profileOf( + frame: ByteBuffer, + size: Int, + ): Int = libDovi.getFrameInfo(frame, size)?.doviProfile ?: 0 + + override fun convertToProfile8( + frame: ByteBuffer, + size: Int, + ): Int = libDovi.processHevcFrame(frame, size, DOVI_TRANSFORM_CONVERT_TO_PROFILE_8, false) + + private companion object { + /** `doviTransform` value of `LibDovi.processHevcFrame` for the profile 7 → 8.1 rewrite. */ + const val DOVI_TRANSFORM_CONVERT_TO_PROFILE_8 = 1 + } +} + +/** + * Converts Dolby Vision profile 7 access units to profile 8.1 on their way into the decoder. + * + * Profile 7 carries its RPU in NAL unit type 62 and its enhancement layer in type 63, after the + * picture's own NAL units. The conversion drops every type 63 unit and has libdovi rewrite the RPU + * into the profile 8.1 form, which is what a profile 8 decoder switches the display into Dolby + * Vision for. The enhancement layer goes because profile 8.1 is single layer by definition: a + * decoder handed a dual layer stream under that name keeps it out of the Dolby Vision pipeline and + * shows the HDR10 base layer, which is the very symptom this filter exists to fix. + * + * Every access unit is judged on its own. One without an RPU, or whose RPU is not profile 7, is + * left exactly as it came, and so is one libdovi refuses. The rewrite happens in a scratch buffer + * and is copied back only once it has succeeded and fits, so a refused access unit reaches the + * decoder untouched rather than half rewritten. + * + * The codec string of the track has to say profile 8 for the decoder to be chosen at all; that is + * [DolbyVisionProfile7ExtractorsFactory]'s job. An instance belongs to one video renderer and is + * called from its playback thread only. + */ +class DolbyVisionProfile7Filter( + private val newRewriter: () -> RpuRewriter = { LibDoviRpuRewriter() }, +) : BitstreamFilter { + private var rewriter: RpuRewriter? = null + private var rewriterUnavailable = false + + /** The access unit as it arrived, for scanning: a heap array is much faster to walk than absolute gets on a direct buffer. */ + private var staging = ByteArray(0) + + /** The access unit without its enhancement layer, where libdovi rewrites the RPU. Direct, because the native code needs its address. */ + private var scratch: ByteBuffer? = null + + private var convertedCount = 0 + private var passedThroughCount = 0 + private var refusedCount = 0 + + override fun filter( + data: ByteBuffer, + offset: Int, + size: Int, + ): Int { + if (!startsWithStartCode(data, offset, size)) { + return size + } + val rewriter = rewriter() ?: return size + + val staging = staging(size) + window(data, offset, size).get(staging, 0, size) + if (!carriesRpu(staging, size)) { + passedThroughCount++ + return size + } + + val scratch = scratch(size + RPU_GROWTH_HEADROOM_BYTES) + val kept = copyWithoutEnhancementLayer(staging, size, scratch) + val converted = + try { + if (rewriter.profileOf(scratch, kept) != DOLBY_VISION_PROFILE_7) { + passedThroughCount++ + return size + } + rewriter.convertToProfile8(scratch, kept) + } catch (e: RuntimeException) { + refuse("the RPU rewrite threw", e) + return size + } + if (converted <= 0 || converted > size) { + refuse("the RPU rewrite returned $converted bytes for a $size byte access unit", null) + return size + } + + window(data, offset, converted).put(window(scratch, 0, converted)) + convertedCount++ + return converted + } + + private fun rewriter(): RpuRewriter? { + if (rewriterUnavailable) { + return null + } + return rewriter ?: try { + newRewriter().also { rewriter = it } + } catch (e: LinkageError) { + // No libdovi for this ABI. Playback goes on exactly as it did without the filter. + Timber.w(e, "libdovi is not available on this device, Dolby Vision profile 7 is left as it is") + rewriterUnavailable = true + null + } + } + + private fun refuse( + reason: String, + e: Throwable?, + ) { + if (refusedCount == 0) { + Timber.w(e, "Leaving a Dolby Vision profile 7 access unit as it is: %s", reason) + } + refusedCount++ + } + + private fun staging(size: Int): ByteArray { + if (staging.size < size) { + staging = ByteArray(maxOf(size, INITIAL_CAPACITY_BYTES)) + } + return staging + } + + private fun scratch(capacity: Int): ByteBuffer { + val current = scratch + val buffer = + if (current == null || current.capacity() < capacity) { + ByteBuffer.allocateDirect(maxOf(capacity, INITIAL_CAPACITY_BYTES)).also { scratch = it } + } else { + current + } + (buffer as Buffer).clear() + return buffer + } + + override fun toString(): String = + "Dolby Vision profile 7 → 8.1 ($convertedCount access units converted, " + + "$passedThroughCount passed through, $refusedCount refused)" + + private companion object { + const val DOLBY_VISION_PROFILE_7 = 7 + + /** HEVC NAL unit type carrying the Dolby Vision RPU. */ + const val NAL_UNIT_TYPE_RPU = 62 + + /** HEVC NAL unit type carrying the Dolby Vision enhancement layer picture. */ + const val NAL_UNIT_TYPE_ENHANCEMENT_LAYER = 63 + + /** Big enough for a UHD access unit without regrowing on every early frame. */ + const val INITIAL_CAPACITY_BYTES = 512 * 1024 + + /** + * Room past the access unit for an RPU which comes out of the rewrite longer than it went + * in. Dropping the enhancement layer usually frees far more, but a profile 7 stream whose + * enhancement layer the extractor never delivered has none to free. + */ + const val RPU_GROWTH_HEADROOM_BYTES = 4 * 1024 + + private fun startsWithStartCode( + data: ByteBuffer, + offset: Int, + size: Int, + ): Boolean { + if (size < 4) { + return false + } + return data.get(offset) == 0.toByte() && + data.get(offset + 1) == 0.toByte() && + ( + data.get(offset + 2) == 1.toByte() || + (data.get(offset + 2) == 0.toByte() && data.get(offset + 3) == 1.toByte()) + ) + } + + /** Whether the Annex B access unit in `[0, length)` of [source] carries an RPU. */ + private fun carriesRpu( + source: ByteArray, + length: Int, + ): Boolean { + var unit = 0 + while (unit < length) { + val payload = unit + startCodeLength(source, unit) + if (payload >= length) { + return false + } + if (nalUnitType(source[payload]) == NAL_UNIT_TYPE_RPU) { + return true + } + unit = nextStartCode(source, payload, length) + } + return false + } + + /** + * Copies the Annex B access unit in `[0, length)` of [source] into [target] from index 0, + * leaving out every enhancement layer NAL unit, and returns the number of bytes copied. + */ + fun copyWithoutEnhancementLayer( + source: ByteArray, + length: Int, + target: ByteBuffer, + ): Int { + var unit = 0 + var written = 0 + while (unit < length) { + val payload = unit + startCodeLength(source, unit) + if (payload >= length) { + // A start code with nothing behind it. Not something to interpret; kept as it came. + target.put(source, unit, length - unit) + written += length - unit + break + } + val next = nextStartCode(source, payload, length) + if (nalUnitType(source[payload]) != NAL_UNIT_TYPE_ENHANCEMENT_LAYER) { + target.put(source, unit, next - unit) + written += next - unit + } + unit = next + } + return written + } + + /** + * A view of `[offset, offset + length)` of [data] with a position and limit of its own, so + * that bulk copies leave the buffer's untouched. The position and limit calls go through + * [Buffer]: the [ByteBuffer] overrides which return a ByteBuffer are API 34, and `minSdk` is 23. + */ + private fun window( + data: ByteBuffer, + offset: Int, + length: Int, + ): ByteBuffer { + val view = data.duplicate() + val buffer: Buffer = view + buffer.clear() + buffer.position(offset) + buffer.limit(offset + length) + return view + } + + /** nal_unit_type is the 6 bits after forbidden_zero_bit, H.265 section 7.3.1.2. */ + private fun nalUnitType(header: Byte): Int = (header.toInt() shr 1) and 0x3F + + /** Length of the start code at [at], which the caller knows is one: 3 or 4 bytes. */ + private fun startCodeLength( + data: ByteArray, + at: Int, + ): Int = if (data[at + 2] == 1.toByte()) 3 else 4 + + /** + * Index of the next Annex B start code at or after [from], or [limit] if there is none. A + * fourth leading zero byte counts as part of the start code, which is the form media3's + * extractors emit; three byte codes are walked all the same. + */ + private fun nextStartCode( + data: ByteArray, + from: Int, + limit: Int, + ): Int { + var i = from + while (i + 2 < limit) { + if (data[i] == 0.toByte() && data[i + 1] == 0.toByte() && data[i + 2] == 1.toByte()) { + return if (i > from && data[i - 1] == 0.toByte()) i - 1 else i + } + i++ + } + return limit + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 5c5004ac..f1384a27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -12,7 +12,6 @@ import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.MediaStreamProtocol import org.jellyfin.sdk.model.api.ProfileConditionValue import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod -import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile @@ -71,10 +70,11 @@ fun createDeviceProfile( downMixAudio: Boolean, assDirectPlay: Boolean, pgsDirectPlay: Boolean, - dolbyVisionELDirectPlay: Boolean, decodeAv1: Boolean, jellyfinTenEleven: Boolean, preferAc3ForSurround: Boolean, + forceEnabledHdr: Set, + forceDisabledHdr: Set, maxResolution: Int, ) = buildDeviceProfile { val allowedAudioCodecs = @@ -148,9 +148,9 @@ fun createDeviceProfile( videoCodec(Codec.Video.H264) if (preferAc3ForSurround) { - audioCodec(*allowedAudioCodecs) - } else { audioCodec(Codec.Audio.AC3) + } else { + audioCodec(*allowedAudioCodecs) } copyTimestamps = false @@ -467,60 +467,56 @@ fun createDeviceProfile( // / HDR exclude list - // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesAv1 = buildSet { - if (jellyfinTenEleven) add("DOVIInvalid") + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) if (!decodeAv1) { if (!supportsAV1DolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) - if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") + add(WholphinVideoRangeType.DOVI.serialName) + if (!supportsAV1HDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven && !supportsAV1HDR10Plus) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) } if (!supportsAV1HDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) + add(WholphinVideoRangeType.HDR10_PLUS.serialName) - if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + if (!mediaTest.supportsAV1HDR10()) add(WholphinVideoRangeType.HDR10.serialName) } } - } + } - forceEnabledHdr + forceDisabledHdr - // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesHevc = buildSet { - if (jellyfinTenEleven) add("DOVIInvalid") + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) if (!supportsHevcDolbyVisionEL) { - if (!dolbyVisionELDirectPlay) { - if (jellyfinTenEleven) { - add("DOVIWithEL") - if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus") + if (jellyfinTenEleven) { + add(WholphinVideoRangeType.DOVI_WITH_EL.serialName) + if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { + add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) } } if (!supportsHevcDolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsHevcHDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) + add(WholphinVideoRangeType.DOVI.serialName) + if (!supportsHevcHDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) if (jellyfinTenEleven && !supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { - add( - "DOVIWithHDR10Plus", - ) + add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) } } } if (!supportsHevcHDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) - if (!supportsHevcHDR10) add(VideoRangeType.HDR10.serialName) + add(WholphinVideoRangeType.HDR10_PLUS.serialName) + if (!supportsHevcHDR10) add(WholphinVideoRangeType.HDR10.serialName) } if (jellyfinTenEleven && KnownDefects.hevcDoviHdr10PlusBug) { - add("DOVIWithHDR10Plus") - add("DOVIWithELHDR10Plus") + add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) } - } + } - forceEnabledHdr + forceDisabledHdr // Display // Note: The codec profiles use a workaround to create correct behavior diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt index ab4de03e..a769c400 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt @@ -209,6 +209,15 @@ class MediaCodecCapabilitiesTest( ) && supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC) + // Checks for single-layer Dolby Vision Profile 8, which the Profile 7 conversion produces + fun supportsHevcDolbyVisionProfile8(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && + hasDecoder( + MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, + DolbyVisionProfiles.Profile8, + CodecProfileLevel.DolbyVisionLevelHd24, + ) + fun supportsHevcHDR10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && hasDecoder( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt new file mode 100644 index 00000000..bd49a6c4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.util.profile + +import org.jellyfin.sdk.model.api.VideoRangeType + +/** + * Complete list of video range types, including ones not yet present in the pinned Jellyfin SDK's + * [VideoRangeType] enum (added server-side in Jellyfin 10.11). + * + * TODO Remove this and use [VideoRangeType] directly once the SDK is upgraded to a version built against Jellyfin 10.11+ + */ +enum class WholphinVideoRangeType( + val serialName: String, +) { + UNKNOWN(VideoRangeType.UNKNOWN.serialName), + SDR(VideoRangeType.SDR.serialName), + HDR10(VideoRangeType.HDR10.serialName), + HLG(VideoRangeType.HLG.serialName), + DOVI(VideoRangeType.DOVI.serialName), + DOVI_WITH_HDR10(VideoRangeType.DOVI_WITH_HDR10.serialName), + DOVI_WITH_HLG(VideoRangeType.DOVI_WITH_HLG.serialName), + DOVI_WITH_SDR(VideoRangeType.DOVI_WITH_SDR.serialName), + HDR10_PLUS(VideoRangeType.HDR10_PLUS.serialName), + DOVI_INVALID("DOVIInvalid"), + DOVI_WITH_EL("DOVIWithEL"), + DOVI_WITH_EL_HDR10_PLUS("DOVIWithELHDR10Plus"), + DOVI_WITH_HDR10_PLUS("DOVIWithHDR10Plus"), +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 0c3b5cb3..55553166 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -54,10 +54,15 @@ message PlaybackOverrides{ bool direct_play_ass = 3 [deprecated = true]; bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; - bool direct_play_dolby_vision_e_l = 6; + bool direct_play_dolby_vision_e_l = 6 [deprecated = true]; bool decode_av1 = 7; AssPlaybackMode ass_playback_mode = 8; int32 max_resolution = 9; + HdrOverrideMode dovi_profile_5_override = 10; + HdrOverrideMode dovi_profile_7_override = 11; + HdrOverrideMode dovi_profile_8_override = 12; + HdrOverrideMode hdr10_override = 13; + HdrOverrideMode hdr10_plus_override = 14; } message PlaybackPreferences { @@ -225,18 +230,24 @@ message MusicPreferences { bool show_backdrop = 4; } +enum HdrOverrideMode{ + HDR_OVERRIDE_AUTO = 0; + HDR_OVERRIDE_ENABLE = 1; + HDR_OVERRIDE_DISABLE = 2; +} + message ExperimentalPreferences { bool enabled = 1; bool video_tunneling_enabled = 2; bool prefer_ac3_surround = 3; bool disable_audio_offload = 4; bool prefer_dolby_vision_over_hdr10_plus = 5; + bool convert_dolby_vision_profile_7 = 6; } message AppPreferences { - // The currently signed in server and user IDs, mostly for restoring a session - string current_server_id = 1; - string current_user_id = 2; + string current_server_id = 1 [deprecated = true]; + string current_user_id = 2 [deprecated = true]; PlaybackPreferences playback_preferences = 3; HomePagePreferences home_page_preferences = 4; diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f4523d02..7170a274 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -719,4 +719,44 @@ Von Wiedergabeliste entfernen Anmeldeinformationen abgelaufen, bitte erneut anmelden Du bist aktuell nicht berechtigt auf den Server zuzugreifen + Dolby Vision gegenüber HDR10+ bevorzugen + Entfernt HDR10+ Metadaten von Dolby Vision Videos für TVs, welche die Dateien sonst als HDR10+ abspielen würden + Maximale Auflösung vor dem Transkodieren + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Folge + %1$d Folgen + + Am besten bewertet + HDR Formate + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Benötigte Server Version: %1$s + Benutzer + Standard vom Server laden + Nur auf diesem Gerät speichern + Standard + Geräte spezifisch + Server Benutzerprofil + Server Standard + Benutzer Webclient + Vom Server verwaltet + Fehler + Server Version wird bald nicht mehr unterstützt + Festgelegt von %1$s + Dolby Vision Profile 7 zu 8.1 konvertieren + Für Geräte, die zwar Dolby Vision Profil 8, aber nicht Profil 7 dekodieren können, wird das zweischichtige Profil 7 vor der Wiedergabe in das einschichtige Profil 8.1 umgewandelt und dann direkt abgespielt + Interpreten Quelle + Reihen + Aktivieren erzwingen + Deaktivieren erzwingen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 696ad927..714ae611 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -741,4 +741,19 @@ Eliminar de la lista de reproducción Credenciales caducadas, vuelve a iniciar sesión No tienes autorización para acceder al servidor en este momento + Preferir Dolby Vision sobre HDR10+ + Elimina los metadatos HDR10+ de vídeos en Dolby Vision para televisores que, de lo contrario, los reproducirían como HDR10+ + Resolución máxima antes de transcodificar + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Episodio + %1$d Episodios + %1$d Episodios + diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 8fc29c91..2008bf2c 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -719,4 +719,44 @@ Eemalda esitusloendist Autentimiseandmed on aegunud, palun logi uuesti sisse Sul pole hetkel õigusi ligipääsuks sellele serverile + HDR10+ asemel eelista Dolby Visionit + Kui teler muul juhul kasutaks esituseks HDR10+ vormingut, siis eemaldab HDR10+ metateabe Dolby Visioni videotest + Maksimaalne resolutsioon enne kodeeringu muutmist + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d osa + %1$d osa + + Parima hinnanguga + HDR-vormingud + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Vajalik serveriversioon: %1$s + Serveriversioon ei saa lähitulevikus olema toetatud + Kasutaja + Sai + Vaikimisi laadi serverist + Salvesta vaid siia seadmesse + Vaikimisi + Seadmekohane + Kasutajaprofiil serveris + Vaikimisi väärtus serveris + Kasutaja veebiklient + Valija: %1$s + Jõusta sundkorras + Lõpeta sundkorras jõustamine + Hallatud serveri poolt + Viga + Teisenda Dolby Vision Profile 7 → 8.1 + Muudab kahekihilise „Profile 7“ enne dekooderit ühekihiliseks profiiliks 8.1 ja esitab seda seadmetel otse, mille dekooder toetab profiili 8, kuid mitte profiili 7 + Esitaja allikas diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 898f8a10..85603d28 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -724,4 +724,62 @@ Livre audio Version du serveur non prise en charge Apparaît dans + %1$dh %2$ds + S%1$d E%2$d + S%1$d E%2$d-E%3$d + S%1$02dE%2$02d + S%1$02dE%2$02d-E%3$02d + E%1$d + Saison %1$d + Désactiver le délestage matériel audio + La désactivation du déchargement du décodage audio peut résoudre les problèmes de blocage de la lecture sur certains appareils + Préférez Dolby Vision à HDR10+ + Supprime les métadonnées HDR10+ des vidéos Dolby Vision, pour les téléviseurs qui, sans cela, les liraient en HDR10+ + Écran en mode veille après un certain délai + Pourcentage de réduction de luminosité + Écran sombre + Albums photos + Aucun favori trouvé ! + Supprimer de la liste de lecture + Vos identifiants ont expiré. Veuillez vous reconnecter + Vous n\'êtes pas autorisé(e) à accéder au serveur pour le moment + Résolution maximale avant le transcodage + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Épisode + %1$d Épisodes + %1$d Épisode + + Les mieux notés + Formats HDR + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Activation forcée + Désactivation forcée + La version du serveur ne sera bientôt plus prise en charge + Version requise du serveur : %1$s + Sélectionné par %1$s + Utilisateur + Séries + Charger les paramètres par défaut depuis le serveur + Enregistrer uniquement sur cet appareil + Défaut + Spécifique à l\'appareil + Profil utilisateur du serveur + Paramètres par défaut du serveur + Client Web utilisateur + Convertir le profil Dolby Vision 7 en 8.1 + Convertit le profil 7 double couche en profil 8.1 simple couche avant le décodeur et le lit directement, pour les appareils dont le décodeur prend en charge le profil 8 mais pas le profil 7 + Source de l\'artiste + Géré par le serveur + Erreur diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 64d8c3b1..f078a5f7 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -25,7 +25,7 @@ Rendező Letiltva Felfedezett szerverek - Letöltés & Frissítés + Letöltés folyamatban… Engedélyezve Adja meg a szerver IP-t vagy URL-t @@ -43,7 +43,7 @@ Kiegészítő Műfajok Ugrás a sorozathoz - Ugrás ide: + Ugrás ide Lejátszási gombok elrejtése Hibakeresési infó elrejtése Elrejt @@ -95,7 +95,7 @@ Újraindítás Folytatás Mentés - Keresés & Letöltés + Keresés Keresés… Szerver kiválasztása @@ -121,7 +121,7 @@ Váltás Legjobbra értékelt, nem megnézett - Előzetesek + Előzetes Előzetesek DVR Időzítés @@ -138,6 +138,7 @@ Videók Nézze élőben + %1$d éves %1$d éves Lejátszás átalakítással @@ -159,8 +160,8 @@ Egyebek - Színfalak mögött - Színfalak mögött + A kulisszák mögött + A kulisszák mögött Főcímdal @@ -200,19 +201,19 @@ %s letöltés - %s letöltés + %s letöltések %d óra - %d óra + %d órák %d elem - %d elem + %d elemek %d másodperc - %d másodperc + %d másodpercek %1$s-kor ér véget Nem található szerver @@ -234,7 +235,7 @@ Tartalom alapértelmezett méretezése Frissítés telepítése Telepített verzió - Elemek száma soronként a kezdőlapon + A kezdőlap sorainak maximális elemszáma Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve Navigációs menü elemeinek testreszabása Kattintson az oldalak közti váltáshoz @@ -303,10 +304,10 @@ PIN-kód eltávolítása Nézetbeállítások Oszlopok - Helyköz + Térköz Képarány - Részletek mutatása - Kép típusa + Részletek megjelenítése + Képtípus Vendégszereplők Szegély mérete Tartalomhoz igazodó képfrissítési gyakoriság @@ -354,10 +355,10 @@ Felirat késleltetése Háttér stílusa Adja meg a szerver címét - Folytatás & Következő menü összevonása + Lejátszás backend Kép-gyorsítótár mérete (MB) - Stábtagok + Navigációs menü oldalainak váltása fókusznál Lejátszás felülbírálatai Felbontásváltás @@ -372,4 +373,275 @@ DTS Csak kiegészítő felirat Sávválasztás törlése + %1$dó %2$dp %3$dmp + %1$dó %2$dp + %1$dó %2$dmp + %1$dp %2$dmp + %1$dó + %1$dp + %1$dmp + Felhasználói profilbeállítások a szerverről + Hangalapú keresés + Indítás + Keresés hanggal + Feldolgozás + A megszakításhoz nyomd meg a Vissza gombot + Hangfelvételi hiba + Hangfelismerési hiba + Mikrofonengedély szükséges + Hálózati hiba + Hálózati időtúllépés + Nem sikerült felismerni a beszédet + A hangfelismerés foglalt + Szerverhiba + Nem észlelhető beszéd + Ismeretlen hiba + Nem sikerült elindítani a hangfelismerést + A hangfelismerés időtúllépés miatt megszakadt + Újrapróbálás + Előzetesek + %1$d. évad + Egyéb + Werkfilmek + Főcímdalok + Főcímvideók + Klipek + Törölt jelenetek + Interjúk + Jelenetek + Minták + Rövid werkfilmek + Rövidfilmek + Kedvenc %s + + %s nap + %s napok + + + %s perc + %s percek + + + Film + Filmek + + + Sorozat + Sorozatok + + + Személy + Személyek + + Bejelentkezés szükséges a profil használatához + [0–9] + Hardveres hangfeldolgozás kikapcsolása + A hardveres hangdekódolás kikapcsolása egyes eszközökön megoldhatja a lejátszás közbeni lefagyásokat + Összesített keresési találatok + Az összes találat megjelenítése egyetlen listában + Találatok megjelenítése kategóriánként + Hangalapú keresés gomb megjelenítése + Látható + Rejtett + Találatok + Dolby Vision 7-es profil közvetlen lejátszása + Figyelmen kívül hagyja az eszközkompatibilitás ellenőrzését + A Dolby Vision előnyben részesítése a HDR10+-szal szemben + Eltávolítja a HDR10+ metaadatokat a Dolby Vision-videókból azoknál a tévéknél, amelyek egyébként HDR10+-ként játszanák le őket + Felfedezés + Kérés + Függőben + Seerr-integráció + Seerr-szerver eltávolítása + Seerr-szerver hozzáadva + Gyors csatlakozás + Engedélyezd egy másik eszköznek a bejelentkezést a fiókodba + Add meg a gyors csatlakozási kódot + A kódnak 6 számjegyből kell állnia + Az eszköz engedélyezése sikeres + Jelszó + Felhasználónév + URL + Népszerű most + Hamarosan megjelenő filmek + Hamarosan induló sorozatok + Kérés 4K-ban + AV1 szoftveres dekódolása + Az MPV mostantól az alapértelmezett lejátszó, kivéve HDR-tartalmak esetén. \nEzt a beállításokban módosíthatod. + HDR-feliratok stílusa + Képalapú feliratok átlátszatlansága + Fényerő + Kontraszt + Telítettség + Színárnyalat + Mentés albumhoz + Diavetítés indítása + Diavetítés leállítása + Az elején + Nincs több fénykép + Forgatás balra + Forgatás jobbra + Nagyítás + Kicsinyítés + Diavetítés időtartama + Videók lejátszása diavetítés közben + Médiaadat-napló küldése a szervernek + Korlátlan + A „Következő” listában megjelenő napok maximális száma + Albumok + Előadók + Dalok + Ugrás az előadóhoz + Most játszott tartalom + Azonnali mix + Ugrás az albumhoz + Hozzáadás a lejátszási sorhoz + Sor hozzáadása + Műfajok %1$s + Nemrég megjelent %1$s + Magasság + Alkalmazás az összes sorra + Kezdőlap testreszabása + Kezdőlap sorai + Betöltés a szerver felhasználói profiljából + Mentés a szerver felhasználói profiljába + Betöltés a webes kliensből + Sorozat képének használata + Sor hozzáadása ehhez: %1$s + Felülírod a szerveren lévő beállításokat? + Felülírod a helyi beállításokat? + Epizódokhoz + Javaslatok ehhez: %1$s + Az összes kártya méretének növelése + Az összes kártya méretének csökkentése + Bélyegképek használata + Beállítások mentve + Kijelző-beállítások + Beépített előbeállítások az összes sor gyors formázásához + Sorok és képek kiválasztása a kezdőlapon + + Térhatású hang használata AC3 formátumban + Kezdés ennyi idő múlva + Animálás + Maximális korhatár + Nincs felső korlát + Minden korosztály számára + %s éves korig + Képernyővédő + Képernyővédő beállítások + Képernyővédő elindítása + Időtartam + Fotók + Típusok felvétele + Mely elemtípusokhoz jelenjenek meg képek + Illesztés + Kivágás + Kitöltés + Szélesség kitöltése + Magasság kitöltése + Wholphin alapértelmezett beállítása + Wholphin kompakt elrendezés + A sorozat miniatűrképei + Az epizódok miniatűrképei + Legalacsonyabb + Alacsony + Közepes + Magas + Teljes + Lila + Narancs + Élénk kék + Fekete + Barna + Kihagyás + Automatikus átugrás + Rákérdezés a kihagyásra + Az FFmpeg használata csak akkor, ha nincs beépített dekóder + Az FFmpeg előnyben részesítése a beépített dekóderekkel szemben + FFmpeg-dekóderek használatának tiltása + A lejátszás végén + HDR-formátumok + HDR10 + HDR10+ + Dolby Vision 5-ös profil + Dolby Vision 7-es profil + Dolby Vision 8-as profil + Kényszerített engedélyezés + Kényszerített letiltás + Piros + Zöld + Kék + Elmosódás + A stáblista és a zárójelenetek alatt + Soha + Fehér + Világosszürke + Sötétszürke + Sárga + Cián + Magenta + Körvonal + Árnyék + Sortörés + Az MPV előnyben részesítése + Külső lejátszó + Az ExoPlayer használata HDR-lejátszáshoz + Poszter (2:3) + 16:9 + 4:3 + Négyzet (1:1) + Elsődleges + Bélyegkép + Rács + Lista + Összefoglaló lista + Dinamikus színű kép + Csak kép + + API-kulcs + Bejelentkezés a Seerr-szerverre + Jellyfin-felhasználó + Helyi felhasználó + + Nem található távoli felirat + Jelenlegi + Az alkalmazáson belüli képernyővédő használata + Biztosan törölni szeretnéd ezt az elemet? + Médiakezelési lehetőségek megjelenítése + Keresés %s + Színész + Zeneszerző + Író + Vendégszereplő + Producer + Karmester + Dalszövegíró + Rendezés + Mérnök + Hangmérnök + Alkotó + Művész + Album előadója + Album + Népszerű dalok + Lejátszás következőként + Videóklipek + Dalszövegek + Dalszöveg elrejtése + Dalszöveg megjelenítése + Eltávolítás a lejátszási sorból + Albumborító megjelenítése + Vizuális effektusok megjelenítése + Háttér megjelenítése + Lejátszás másként? + A jelenleg lejátszott hang vizualizálásához engedélyezni kell a hangrögzítést. + Folytatás + Összes kijelölése + Típusok különválasztása + A címek logóinak előnyben részesítése + A Wholphin frissült a %s verzióra + Eltávolítás a „Folytatás” listából + Sorozat eltávolítása a „Következő epizód” listából + A „Következő epizód” listából eltávolított sorozatok megtekintése + További információk diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index d6f20d4b..623ef596 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -694,4 +694,20 @@ Redupkan layar Album foto Favorit tidak ditemukan! + + %1$d episode + + Utamakan Dolby Vision dibanding HDR10+ + Hapus metadata HDR10+ dari video Dolby Vision, untuk TV yang dapat memutarnya sebagai HDR10+ + Hapus dari playlist + Kredensial kedaluwarsa, silakan login kembali + Kamu tidak diizinkan mengakses server saat ini + Resolusi maksimum sebelum transkode + 8K + 4K + 1080 + 720 + 576 + 480 + 360 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index fb5f37ce..be2f53e3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -741,4 +741,42 @@ Rimuovi dalla playlist Credenziali scadute, effettua di nuovo l’accesso Non sei autorizzato ad accedere al server in questo momento + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Episodio + %1$d Episodi + %1$d Episodi + + Preferisci Dolby Vision a HDR10+ + Rimuove i metadati HDR10+ dai video Dolby Vision, per le TV che altrimenti li riproducono come HDR10+ + Risoluzione massima prima della trascodifica + HDR10 + HDR10+ + I più votati + Formati HDR + Dolby Vision Profilo 5 + Dolby Vision Profilo 7 + Dolby Vision Profilo 8 + Forza abilitazione + Forza disabilitazione + La versione del server non sarà più supportata a breve + Versione del server richiesta: %1$s + Serie + Errore + Utente + Carica predefinito dal server + Salva solo su questo dispositivo + Predefinito + Specifico del dispositivo + Profilo utente del server + Predefinito del server + Client web utente + Gestito dal server + Scelto da %1$s diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index fe4ce431..d05db022 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -220,7 +220,7 @@ בגיל שנתיים בגיל %1$d - נגן עם המרת קידוד + נגן עם המרת־קידוד פרטי המדיה הצג שעון סדר שידור הפרקים @@ -613,7 +613,7 @@ התנהגות דילוג עבור פתיחים, כתוביות סיום וכו\' נגן עם - המרת קידוד + המרת־קידוד NAL AVC SDR @@ -738,4 +738,22 @@ עונה %1$02d פרקים %2$02d-%3$02d פרק %1$d עונה %1$d + העדפת Dolby Vision על פני HDR10+ + מחיקת מטא-נתונים של HDR10+ ממדיה מסוג Dolby Vision עבור טלוויזיות שמעדיפות לנגן HDR10+ במצב זה + מחיקה מרשימת־השמעה + פרטי ההתחברות פגו, נא להתחבר מחדש + אינך מורשה לגשת לשרת בשלב זה + רזולוציה מירבית לפני המרת־קידוד + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + פרק %1$d + %1$d פרקים + %1$d פרקים + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 00000000..045e125f --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 83247c34..3bb8567e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -763,4 +763,46 @@ S%1$02d O%2$02d-%3$02d Wyłącz sprzętowe wsparcie dźwięku Wyłączenie sprzętowego wsparcie dźwięku może naprawić błąd z zatrzymaniem się odtwarzania na niektórych urządzeniach + 8K + 4K + 1080p + 720p + 576p + 480p + 360p + + %1$d odcinek + %1$d odcinki + %1$d odcinków + %1$d odcinków + + Maksymalna rozdzielczość przed transkodowaniem + Preferuj Dolby Vision nad HDR10+ + Usuwa metadane HDR10+ z filmów Dolby Vision, dla telewizorów, które w przeciwnym razie odtwarzają je jako HDR10+ + Najlepiej oceniane + Formaty HDR + HDR10 + HDR10+ + Dolby Vision P5 + Dolby Vision P7 + Dolby Vision P8 + Wymuś włączenie + Wymuś wyłączenie + Wersja serwera wkrótce przestanie być wspierana + Wymagana wersja serwera: %1$s + Wybrane przez %1$s + Użytkownik + Seriale + Załaduj domyślne z serwera + Zapisuj tylko na tym urządzeniu + Domyślnie + Zależne od urządzenia + Profil użytkownika na serwerze + Domyślnie z serwera + Użytkownik klienta webowego + Konwertuj Dolby Vision 7 do 8.1 + W przypadku urządzeń, których dekoder obsługuje profil 8, ale nie obsługuje profilu 7, system przekształca dwuwarstwowy profil 7 na jednowarstwowy profil 8.1 przed przekazaniem go do dekodera, umożliwiając bezpośrednie odtwarzanie + Źródło artysty + Zarządzane przez serwer + Błąd diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index d65ac309..f65e53af 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -741,4 +741,44 @@ Remover da lista de reprodução As credenciais expiraram. Inicie sessão novamente Não tem autorização para aceder ao servidor neste momento + Mais votados + Formato HDR + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Ativação forçada + Desativação forçada + Preferir Dolby Vision sobre HDR10+ + Remove os metadados HDR10+ dos vídeos em Dolby Vision para televisores que, de outra forma, os reproduziriam como HDR10+ + Converte Dolby Vision Profile 7 para 8.1 + Converte o Perfil 7 de camada dupla no Perfil 8.1 de camada única antes do descodificador e reproduz-o diretamente, para dispositivos cujo descodificador suporta o Perfil 8, mas não o Perfil 7 + A versão do servidor deixará de ser suportada em breve + Versão do servidor necessária: %1$s + Resolução máxima antes da transcodificação + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Episódio + %1$d Episódios + %1$d Episódios + + Escolhido por %1$s + Utilizador + Séries + Carregar as predefinições do servidor + Guardar apenas neste dispositivo + Padrão + Específico do dispositivo + Perfil de utilizador do servidor + Padrão do servidor + Utilizador do cliente web + Gerido pelo servidor + Erro diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f7cc6e4c..ca59ccb6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -763,4 +763,43 @@ Убрать из плейлиста Срок действия учётных данных истёк. Пожалуйста, войдите снова У вас нет доступа к серверу в данный момент + С высоким рейтингом + Форматы HDR + HDR10 + HDR10+ + Dolby Vision, профиль 5 + Dolby Vision, профиль 7 + Dolby Vision, профиль 8 + Принудительно включить + Принудительно отключить + Приоритет Dolby Vision над HDR10+ + Удаляет метаданные HDR10+ из видео Dolby Vision для телевизоров, которые иначе воспроизводят такие видео в формате HDR10+ + Поддержка этой версии сервера скоро прекратится + Требуемая версия сервера: %1$s + Максимальное разрешение без перекодирования + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d эпизод + %1$d эпизода + %1$d эпизодов + %1$d эпизодов + + Источник выбора: %1$s + Пользователь + Сериал + Загрузить настройки по умолчанию с сервера + Сохранить только на этом устройстве + По умолчанию + Настройки этого устройства + Профиль пользователя на сервере + Настройки по умолчанию на сервере + Веб-клиент пользователя + Управляется сервером + Ошибка diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index bc78a1f8..50d2edd7 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -763,4 +763,20 @@ Odstrániť z playlistu Platnosť prihlasovacích údajov vypršala, prihláste sa znova Momentálne nemáte oprávnenie na prístup k serveru + Preferovať Dolby Vision pred HDR10+ + Odstraňuje HDR10+ metadáta z Dolby Vision videí, aby ich televízory namiesto HDR10+ prehrávali v režime Dolby Vision + Maximálne rozlíšenie pred prekódovaním + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d epizóda + %1$d epizódy + %1$d epizód + %1$d epizód + diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 3df78be2..4244a0df 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -1,4 +1,134 @@ O aplikaciji + Aktivni Snimci + Omiljeni + Dodaj Server + Dodaj Korisnika + Audio + Mesto rođenja + Bitrate + Rođen + Prekini Snimanje + Prekini Snimanje Serije + Prekini + Poglavlja + Izaberi + Očisti keš slika + Kolekcija + Kolekcije + Reklama + Ocena Zajednice + Greška! Pritisni dugme da pošalješ logove serveru. + Potvrdi + Nastavi sa gledanjem + Ocena Kritičara + %.2f sekunde + %1$dh %2$dm %3$ds + %1$dh %2$dm + %1$dh %2$ds + %1$dm %2$ds + %1$dh + %1$dm + %1$ds + Podrazumevano + Obriši + Umro + Režirao %1$s + Režiser + Isključeno + Otkriveni Serveri + + Preuzimanje… + Uključeno + Završava se u %1$s + Upiši Server IP ili URL + Upiši server adresu + Epizode + Greška pri učitavanju kolekcije %1$s + Eksterni + Omiljeni + Veličina + Forsiran + Žanrovi + Uđi u serije + Idi do + Sakrij kontrole za reprodukciju + Sakrij debug informacije + Sakrij + Kuća + Odmah + Intro + #ABCČĆDĐEFGHIJKLMNOPRSŠTUVZŽ + Biblioteka + Informacije o licenci + TV Uživo + Učitavanje… + Obeleži celu seriju kao odgledanu? + Obeleži celu seriju kao neodgledanu? + Obeleži kao neodgledano + Obeleži kao odgledano + Maks bitrate + Slično ovome + Još + Filmovi + Ime + Sledeće + Nema podataka + Nema rezultata + Server nije pronađen + Nema zakazanih snimaka + Nema dostupnih ažuriranja + Nijedan + Samo Forsirani Titlovi + Outro + Putanja + Ljudi + Broj Prikaza + Pusti odavde + Pusti + Prikaži debug info reprodukcije + Brzina Reprodukcije + Reprodukcija + Plejlista + Plejliste + Pregled + Korisnička Podešavanja + Korisnička podešavanja sa servera + Red + Rekapitulacija + Nedavno dodato u %1$s + Nedavno dodato + Nedavno Snimljeno + Nedavno Objavljeno + Preporučeno + Snimi Program + Snimi Seriju + Ukloni iz omiljenih + Restart + Nastavi + Sačuvaj + + Pretraži + Pretraga… + Glasovna pretraga + Započinje + Pretraži govorom + Obrađuje se + Pritisni nazad za prekid + Greška u audio zapisu + Greška u prepoznavanju glasa + Potrebna je dozvola za mikrofon + Greška u mreži + Mreža izgubljena + Nije prepoznat govor + Prepoznavanje govora zauzeto + Greška u Serveru + Nije detektovan govor + Nepoznata greška + Greška u pokretanju prepoznavanja glasa + Greška u prepoznavanju glasa + Pokušaj ponovo + Odaberi Server + Odaberi Korisnika diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 3b71517a..002d5ece 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -686,4 +686,9 @@ Användarvisningar %1$sfilmer Kvalitetsprofil + HDR-format + HDR10 + HDR10+ + Tvinga aktivering + Tvinga avaktivering diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index f591c3fb..70804da5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -13,7 +13,7 @@ Dizi kaydını iptal et İptal Bölümler - Seç: %1$s + %1$s ögesini seçin Resim önbelleğini temizle Koleksiyon Koleksiyonlar @@ -718,4 +718,45 @@ Oynatma listesinden kaldır Oturum süresi doldu, lütfen tekrar giriş yapın Şu anda sunucuya erişim yetkiniz bulunmuyor + HDR10+ yerine Dolby Vision\'ı tercih et + Aksi takdirde bu videoları HDR10+ olarak oynatacak TV\'ler için, Dolby Vision videolardan HDR10+ meta verilerini kaldırır + Dönüştürme öncesi maksimum çözünürlük + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Bölüm + %1$d Bölüm + + Yer Aldığı + En Yüksek Puanlı + HDR Formatları + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Zorla Etkinleştir + Zorla Devre Dışı Bırak + Sunucu sürümü yakında desteklenmeyecek + Gerekli sunucu sürümü: %1$s + Seçen: %1$s + Kullanıcı + Diziler + Sunucudan varsayılanları yükle + Yalnızca bu cihaza kaydet + Varsayılan + Cihaza özel + Sunucu kullanıcı profili + Sunucu varsayılanı + Kullanıcı web istemcisi + Sunucu tarafından yönetiliyor + Hata + Dolby Vision Profile 7\'yi 8.1\'e Dönüştür + Profil 8\'i destekleyen ancak Profil 7\'yi desteklemeyen çözücülere sahip cihazlar için; çift katmanlı Profil 7\'yi çözücüye ulaşmadan önce tek katmanlı Profil 8.1\'e dönüştürür ve doğrudan oynatır + Sanatçı Kaynağı diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7c10daab..c82ff0d8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -697,4 +697,36 @@ 從播放清單中移除 您目前無存取此伺服器的權限 登入憑證已過期,請重新登入 + + %1$d 集 + + Dolby Vision 優先於 HDR10+ + 移除 Dolby Vision 影片中的 HDR10+ 中介資料,以解決部分電視誤播為 HDR10+ + 最高解析度,超過將轉碼 + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + HDR10 + HDR10+ + 杜比視界 Profile 5 + 杜比視界 Profile 7 + 杜比視界 Profile 8 + 強制啟用 + 強制停用 + 伺服器版本即將停止支援 + 伺服器版本需為:%1$s + 使用者 + 劇集 + 從伺服器載入預設值 + 僅儲存於此裝置 + 預設 + 高評分 + HDR 格式 + 由 %1$s 精選 + 將杜比視界 Profile 7 轉換為 8.1 + 在解碼器處理前,將雙層 Profile 7 重新改寫為單層 Profile 8.1,讓不支援 Profile 7 但支援 Profile 8 的裝置能夠直接播放影片 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84ad72c7..3783bfdf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -154,6 +154,7 @@ Switch %1$s left Top Rated Unwatched + Top Rated Trailers DVR Schedule Guide @@ -174,7 +175,7 @@ Video Scale Video Videos - Watch liveg + Watch live %1$d years old %1$d year old @@ -414,6 +415,19 @@ Image disk cache size (MB) Disable audio hardware offload Disabling offloading audio decoding may fix playback freezes on some devices + HDR formats + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Force enable + Force disable + + @string/automatic + @string/force_enable + @string/force_disable + View options Columns Spacing @@ -490,6 +504,8 @@ Ignores device compatibility checks Prefer Dolby Vision over HDR10+ Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+ + Convert Dolby Vision Profile 7 to 8.1 + Rewrites dual-layer Profile 7 into single-layer Profile 8.1 before the decoder and direct plays it, for devices whose decoder lists Profile 8 but not Profile 7 Discover Request @@ -792,6 +808,11 @@ Creator Artist Album artist + Artist source + + @string/artist + @string/album_artist + Album Popular songs Play next @@ -920,6 +941,8 @@ Books Audio books Server version not supported + Server version will not be supported soon + Required server version: %1$s Appears on Dim screen after delay Dim percentage @@ -953,4 +976,16 @@ %1$d Episode %1$d Episodes + Chosen by %1$s + User + Series + Load default from server + Save only to this device + Default + Device specific + Server user profile + Server default + User web client + Managed by server + Error diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt similarity index 61% rename from app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt rename to app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt index 556a2fc0..1de5becd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt @@ -1,14 +1,12 @@ -package com.github.damontecres.wholphin.test +package com.github.damontecres.wholphin import android.content.Context import android.content.Intent import android.widget.Toast import androidx.datastore.core.DataStore -import com.github.damontecres.wholphin.MainActivityViewModel import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServerUsers import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.services.AppUpgradeHandler @@ -19,8 +17,10 @@ import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.test.currentUser +import com.github.damontecres.wholphin.test.server +import com.github.damontecres.wholphin.test.user import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.configure import com.github.damontecres.wholphin.util.reset @@ -36,11 +36,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.jellyfin.sdk.model.UUID import org.junit.After -import org.junit.Assert.assertEquals +import org.junit.Assert +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -58,7 +58,6 @@ class TestMainActivityViewModel { private val backdropService: BackdropService = mockk(relaxed = true) private val appUpgradeHandler: AppUpgradeHandler = mockk() private val intentService: IntentService = mockk() - private val serverDao: JellyfinServerDao = mockk() val viewModel = MainActivityViewModel( @@ -74,6 +73,7 @@ class TestMainActivityViewModel { ) private val serverId = UUID.randomUUID() + private val server = server(serverId) private val userId = UUID.randomUUID() private val currentUser = currentUser(serverId, userId) @@ -88,13 +88,6 @@ class TestMainActivityViewModel { WholphinDispatchers.configure(testDispatcher) every { appUpgradeHandler.needUpgrade() } returns false every { appUpgradeHandler.copySubfont(any()) } returns Unit - every { serverRepository.serverDao } returns serverDao - coEvery { serverDao.getServer(serverId) } returns - JellyfinServerUsers( - currentUser.server, - // User list is unused - emptyList(), - ) mockkStatic(Toast::class) val mockToast = mockk() every { Toast.makeText(any(), any(), any()) } returns mockToast @@ -121,22 +114,26 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.restoreLastSession() } returns +// RestoredSession.Success(currentUser) - viewModel.appStart(null) - advanceUntilIdle() + viewModel.handleAppStart() val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.AppContent, + ) destination as SetupDestination.AppContent - assertEquals(currentUser, destination.current) + Assert.assertEquals(currentUser, destination.current) } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } @Test @@ -144,27 +141,27 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = false - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) // coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.getMostRecentServer() } returns + RestoredSession.ServerOnly(server) - viewModel.appStart(null) - advanceUntilIdle() + viewModel.handleAppStart() - verify(exactly = 1) { serverDao.getServer(serverId) } - coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 1) { serverRepository.getMostRecentServer() } val args = mutableListOf() verify { setupNavigationManager.navigateTo(capture(args)) } - assertEquals(2, args.size) + Assert.assertEquals(1, args.size) args[0].let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) - } - args[1].let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) destination as SetupDestination.UserList - assertEquals(currentUser.server, destination.server) + Assert.assertEquals(currentUser.server, destination.server) } } @@ -173,27 +170,30 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } - every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + every { serverRepository.current } returns + MutableStateFlow( + protectedCurrentUser, + ) + coEvery { + serverRepository.tryChangeUser(serverId, userId) + } returns null + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(server) - viewModel.appStart(null) - advanceUntilIdle() + viewModel.handleAppStart() - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } - verify { serverDao.getServer(serverId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } val args = mutableListOf() verify { setupNavigationManager.navigateTo(capture(args)) } - assertEquals(2, args.size) + Assert.assertEquals(1, args.size) args[0].let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) - } - args[1].let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) destination as SetupDestination.UserList - assertEquals(currentUser.server, destination.server) + Assert.assertEquals(currentUser.server, destination.server) } } @@ -202,22 +202,26 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(server) + coEvery { + serverRepository.tryChangeUser(serverId, userId) + } returns null - viewModel.appStart(null) - advanceUntilIdle() + viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 1) { serverRepository.restoreLastSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) destination as SetupDestination.UserList - assertEquals(currentUser.server, destination.server) + Assert.assertEquals(currentUser.server, destination.server) } } @@ -226,45 +230,49 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = true - currentServerId = "" - currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSession(null, null) } returns null + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None - viewModel.appStart(null) - advanceUntilIdle() + viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreSession(null, null) } + coVerify(exactly = 1) { serverRepository.restoreLastSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.ServerList, + ) } } @Test - fun `Test intent result is noop`() = + fun `Test intent result is noop, hot reload`() = runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertFalse(result) - val slot = slot() - verify { setupNavigationManager.navigateTo(capture(slot)) } - slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) - destination as SetupDestination.AppContent - assertEquals(currentUser, destination.current) - } +// val slot = slot() +// verify { setupNavigationManager.navigateTo(capture(slot)) } +// slot.captured.let { destination -> +// Assert.assertTrue( +// "destination is ${destination::class}", +// destination is SetupDestination.AppContent, +// ) +// destination as SetupDestination.AppContent +// Assert.assertEquals(currentUser, destination.current) +// } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } @Test @@ -272,22 +280,22 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertTrue(result) val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) destination as SetupDestination.UserList - assertEquals(currentUser.server, destination.server) + Assert.assertEquals(currentUser.server, destination.server) } } @@ -296,20 +304,20 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertTrue(result) val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.ServerList, + ) } } @@ -318,11 +326,9 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Target( destinations = listOf(Destination.Favorites), @@ -331,21 +337,27 @@ class TestMainActivityViewModel { val backstack = mutableListOf() every { navigationManager.backStack } returns backstack - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertTrue(result) val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } verify { navigationManager.reloadHome() } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.AppContent, + ) destination as SetupDestination.AppContent - assertEquals(currentUser, destination.current) + Assert.assertEquals(currentUser, destination.current) } // Note: navigation manager internally handles Home destination, so it won't be in this list - assertEquals(1, backstack.size) + Assert.assertEquals(1, backstack.size) backstack[0].let { destination -> - assertTrue("destination is ${destination::class}", destination is Destination.Favorites) + Assert.assertTrue( + "destination is ${destination::class}", + destination is Destination.Favorites, + ) } } @@ -354,11 +366,9 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Target( destinations = listOf(Destination.Favorites), @@ -367,20 +377,26 @@ class TestMainActivityViewModel { val backstack = mutableListOf() every { navigationManager.backStack } returns backstack - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertTrue(result) val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } verify(exactly = 0) { navigationManager.reloadHome() } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.AppContent, + ) destination as SetupDestination.AppContent - assertEquals(currentUser, destination.current) + Assert.assertEquals(currentUser, destination.current) } - assertEquals(1, backstack.size) + Assert.assertEquals(1, backstack.size) backstack[0].let { destination -> - assertTrue("destination is ${destination::class}", destination is Destination.Favorites) + Assert.assertTrue( + "destination is ${destination::class}", + destination is Destination.Favorites, + ) } } @@ -389,11 +405,9 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Target( destinations = listOf(Destination.Favorites), @@ -402,14 +416,17 @@ class TestMainActivityViewModel { val backstack = mutableListOf() every { navigationManager.backStack } returns backstack - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertTrue(result) val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } verify(exactly = 0) { navigationManager.reloadHome() } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.ServerList, + ) } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt new file mode 100644 index 00000000..11006322 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt @@ -0,0 +1,48 @@ +package com.github.damontecres.wholphin.data.model + +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.UUID + +class BaseItemSubtitleTest { + private fun album( + albumArtist: String? = null, + artists: List? = null, + productionYear: Int? = null, + ) = BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MUSIC_ALBUM, + name = "Test Album", + albumArtist = albumArtist, + artists = artists, + productionYear = productionYear, + ), + ) + + @Test + fun albumPrefersAlbumArtist() { + val item = album(albumArtist = "Album Artist", artists = listOf("A", "B"), productionYear = 2021) + assertEquals("Album Artist - 2021", item.subtitle) + } + + @Test + fun albumFallsBackToArtists() { + val item = album(artists = listOf("A", "B"), productionYear = 2021) + assertEquals("A, B - 2021", item.subtitle) + } + + @Test + fun albumWithoutYear() { + assertEquals("Album Artist", album(albumArtist = "Album Artist").subtitle) + } + + @Test + fun albumWithoutArtist() { + assertEquals("2021", album(productionYear = 2021).subtitle) + assertNull(album(artists = emptyList()).subtitle) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt new file mode 100644 index 00000000..283ee8ca --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.test.TestTracks +import com.github.damontecres.wholphin.test.assertNotNull +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelativeMediaStreamTest { + @Test + fun `Test getRelativeIndex`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + RelativeMediaStream.getRelativeIndex(0, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(0, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(1, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(2, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(0, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(3, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + } + + @Test + fun `Test getRelativeIndex for stream`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + RelativeMediaStream.getRelativeIndex(streams[1], streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + } + + @Test + fun `Test asRelative basic`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(5, streams.size) + + val relative = streams.asRelative() + // Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + + // Audio + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Video + assertEquals(0, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + } + + @Test + fun `Test asRelative with external`() { + val source = + TestTracks + .Builder() + .addExternalSubtitle(2) + .addSubtitle(2) + .addAudio(2) + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(6, streams.size) + + val relative = streams.asRelative() + // External Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + + // Subtitle + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Audio + assertEquals(0, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + assertEquals(1, relative[5].relativeIndex) + assertEquals(5, relative[5].stream.index) + } + + @Test + fun `Test asRelative with external mixed`() { + val source = + TestTracks + .Builder() + .addExternalSubtitle(1) + .addSubtitle(1) + .addAudio(1) + .addExternalSubtitle(1) + .addAudio(1) + .addSubtitle(1) + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(6, streams.size) + + val relative = streams.asRelative() + // External Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Subtitle + assertEquals(0, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + assertEquals(1, relative[5].relativeIndex) + assertEquals(5, relative[5].stream.index) + + // Audio + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt new file mode 100644 index 00000000..d179c7e1 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt @@ -0,0 +1,207 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.BaseItem +import io.mockk.every +import io.mockk.mockk +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.api.operations.ImageApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.serializer.toUUID +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class ImageUrlServiceTest { + private val mockApiClient = mockk() + private val imageUrlService = ImageUrlService(mockApiClient) + + private val itemId = UUID.randomUUID() + private val seriesId = UUID.randomUUID() + private val seasonId = UUID.randomUUID() + + @Before + fun setup() { + every { mockApiClient.baseUrl } returns "http://localhost" + every { mockApiClient.createUrl(any(), any(), any(), any()) } answers { callOriginal() } + every { mockApiClient.imageApi } returns ImageApi(mockApiClient) + } + + private fun episode( + imageTags: Map = emptyMap(), + parentLogoItemId: UUID? = null, + parentThumbItemId: UUID? = null, + parentBackdropItemId: UUID? = null, + ) = BaseItemDto( + id = itemId, + type = BaseItemKind.EPISODE, + seriesId = seriesId, + seasonId = seasonId, + imageTags = imageTags, + seriesPrimaryImageTag = "seriesPrimary", + parentLogoItemId = parentLogoItemId, + parentLogoImageTag = parentLogoItemId?.let { "parentLogo" }, + parentThumbItemId = parentThumbItemId, + parentThumbImageTag = parentThumbItemId?.let { "parentThumb" }, + parentBackdropItemId = parentBackdropItemId, + parentBackdropImageTags = parentBackdropItemId?.let { listOf("parentBackdrop0", "parentBackdrop1") }, + ) + + private fun movie( + imageTags: Map = emptyMap(), + backdropImageTags: List? = null, + ) = BaseItemDto( + id = itemId, + type = BaseItemKind.MOVIE, + imageTags = imageTags, + backdropImageTags = backdropImageTags, + ) + + private fun assertImage( + url: String?, + expectedId: UUID, + expectedType: ImageType, + expectedTag: String?, + ) { + Assert.assertNotNull(url) + val path = url!!.substringBefore('?') + Assert.assertEquals(expectedId, path.substringAfter("/Items/").substringBefore('/').toUUID()) + Assert.assertEquals(expectedType.serialName, path.substringAfter("/Images/")) + val tag = + url + .substringAfter('?', "") + .split('&') + .firstOrNull { it.startsWith("tag=") } + ?.substringAfter("tag=") + Assert.assertEquals(expectedTag, tag) + } + + @Test + fun `Own images use own tags`() { + val item = + BaseItem( + movie( + imageTags = + mapOf( + ImageType.PRIMARY to "primary", + ImageType.LOGO to "logo", + ImageType.THUMB to "thumb", + ImageType.BANNER to "banner", + ImageType.ART to "art", + ), + backdropImageTags = listOf("backdrop0", "backdrop1"), + ), + ) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, "primary") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, "logo") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.THUMB, "thumb") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), itemId, ImageType.BANNER, "banner") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.ART), itemId, ImageType.ART, "art") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP), itemId, ImageType.BACKDROP, "backdrop0") + } + + @Test + fun `Missing tags are omitted`() { + val item = BaseItem(movie()) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, null) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, null) + Assert.assertNull(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)) + } + + @Test + fun `Thumb falls back to own backdrop`() { + val item = BaseItem(movie(backdropImageTags = listOf("backdrop0"))) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.BACKDROP, "backdrop0") + } + + @Test + fun `Episode primary uses series tag`() { + val item = BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), seriesId, ImageType.BANNER, null) + assertImage( + imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = false), + itemId, + ImageType.PRIMARY, + "primary", + ) + } + + @Test + fun `Season without primary uses series tag`() { + val item = BaseItem(episode().copy(id = seasonId, type = BaseItemKind.SEASON, seasonId = null)) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary") + } + + @Test + fun `Episode logo uses parent logo tag only if from series`() { + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seriesId)), ImageType.LOGO), + seriesId, + ImageType.LOGO, + "parentLogo", + ) + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seasonId)), ImageType.LOGO), + seriesId, + ImageType.LOGO, + null, + ) + } + + @Test + fun `Episode backdrop uses parent backdrop tag only if from series`() { + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seriesId)), ImageType.BACKDROP), + seriesId, + ImageType.BACKDROP, + "parentBackdrop0", + ) + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seasonId)), ImageType.BACKDROP), + seriesId, + ImageType.BACKDROP, + null, + ) + } + + @Test + fun `Episode thumb uses parent tags`() { + assertImage( + imageUrlService.getItemImageUrl( + BaseItem(episode(parentThumbItemId = seriesId, parentBackdropItemId = seriesId), useSeriesForPrimary = true), + ImageType.THUMB, + ), + seriesId, + ImageType.THUMB, + "parentThumb", + ) + assertImage( + imageUrlService.getItemImageUrl( + BaseItem(episode(parentBackdropItemId = seriesId), useSeriesForPrimary = true), + ImageType.THUMB, + ), + seriesId, + ImageType.BACKDROP, + "parentBackdrop0", + ) + assertImage( + imageUrlService.getItemImageUrl( + BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true), + ImageType.THUMB, + ), + itemId, + ImageType.PRIMARY, + "primary", + ) + } + + @Test + fun `Season without thumb uses parent thumb tag`() { + val item = BaseItem(episode(parentThumbItemId = seriesId).copy(id = seasonId, type = BaseItemKind.SEASON)) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), seriesId, ImageType.THUMB, "parentThumb") + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt index ee32c7dd..2bcc6ab7 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt @@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.services import android.app.SearchManager import android.content.Intent import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences @@ -20,7 +20,6 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest @@ -42,7 +41,6 @@ import org.robolectric.annotation.Config class IntentServiceTest { private val api: ApiClient = mockk() private val serverRepository: ServerRepository = mockk() - private val serverDao: JellyfinServerDao = mockk() private val userPreferencesService: UserPreferencesService = mockk() private val userLibraryApi: UserLibraryApi = mockk() @@ -64,7 +62,6 @@ class IntentServiceTest { intentService = IntentService(api, serverRepository, userPreferencesService) every { api.userLibraryApi } returns userLibraryApi coEvery { userLibraryApi.getItem(itemId) } returns successResponse(movie(itemId)) - every { serverRepository.serverDao } returns serverDao } private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { @@ -82,11 +79,11 @@ class IntentServiceTest { private fun setupAutoSignInUnprotected() { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser } @Test @@ -98,7 +95,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertNull(result) - coVerify { serverRepository.restoreSession(serverId, userId) } + coVerify { serverRepository.restoreLastSession() } } @Test @@ -106,17 +103,17 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = false - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -124,17 +121,17 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(protectedCurrentUser.server) val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -142,17 +139,17 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(protectedCurrentUser.server) val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 1) { serverRepository.restoreLastSession() } } @Test @@ -160,17 +157,16 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = "" - currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None // coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -178,12 +174,11 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = "" - currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) val intent = Intent().apply { @@ -193,7 +188,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertNull(result) - coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -201,12 +196,11 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = "" - currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user - coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null +// coEvery { serverRepository.restoreLastSession() } returns +// RestoredSession.ServerOnly(protectedCurrentUser.server) val intent = Intent().apply { @@ -216,7 +210,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -224,12 +218,9 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = "" - currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null - coEvery { serverRepository.restoreSession(serverId, userId) } returns null + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null val intent = Intent().apply { @@ -239,7 +230,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -296,12 +287,11 @@ class IntentServiceTest { every { serverRepository.current } returns MutableStateFlow(currentUser) coEvery { - serverRepository.restoreSession( + serverRepository.tryChangeUser( newServerId, newUserId, ) } returns newCurrentUser - every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user val intent = Intent().apply { @@ -316,9 +306,8 @@ class IntentServiceTest { assertEquals(0, destinations.size) assertTrue(result.addHomeToBackStack) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } - verify { serverDao.getUser(newServerId, newUserId) } - coVerify { serverRepository.restoreSession(newServerId, newUserId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + coVerify { serverRepository.tryChangeUser(newServerId, newUserId) } coVerify(exactly = 0) { userLibraryApi.getItem(itemId) } } @@ -333,12 +322,11 @@ class IntentServiceTest { every { serverRepository.current } returns MutableStateFlow(currentUser) coEvery { - serverRepository.restoreSession( + serverRepository.tryChangeUser( newServerId, newUserId, ) } returns newCurrentUser - every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user val intent = Intent().apply { @@ -358,9 +346,8 @@ class IntentServiceTest { assertEquals(itemId, first.itemId) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } - verify { serverDao.getUser(newServerId, newUserId) } - coVerify { serverRepository.restoreSession(newServerId, newUserId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + coVerify { serverRepository.tryChangeUser(newServerId, newUserId) } coVerify { userLibraryApi.getItem(itemId) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt index 499208d6..2c2fdffe 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt @@ -1,11 +1,13 @@ package com.github.damontecres.wholphin.services import android.content.Context -import android.content.SharedPreferences import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.MostRecentServer +import com.github.damontecres.wholphin.data.MostRecentServerProvider +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServerUsers @@ -14,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.test.nonBlankString +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.successResponse import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.WholphinDispatchers @@ -21,6 +24,7 @@ import com.github.damontecres.wholphin.util.configure import com.github.damontecres.wholphin.util.reset import io.mockk.Runs import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -29,7 +33,6 @@ import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest @@ -48,6 +51,8 @@ import org.jellyfin.sdk.model.api.PublicSystemInfo import org.jellyfin.sdk.model.api.UserDto import org.junit.After import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -80,14 +85,13 @@ class ServerRepositoryTest { private val mockUserApi = mockk() private val mockSystemApi = mockk() - private val mockSharedPreferences = mockk(relaxed = true) + private val mockMostRecentServerProvider = mockk() @OptIn(ExperimentalCoroutinesApi::class) @Before fun setup() { WholphinDispatchers.configure(testDispatcher) - every { mockContext.getSharedPreferences(any(), any()) } returns mockSharedPreferences every { mockApiClient.userApi } returns mockUserApi every { mockApiClient.systemApi } returns mockSystemApi coEvery { mockUserApi.getCurrentUser() } returns successResponse(userDto) @@ -102,9 +106,15 @@ class ServerRepositoryTest { emptyMap(), ) coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + coEvery { mockJellyfinServerDao.getServer(serverId) } returns + JellyfinServerUsers(server, listOf(user)) + coEvery { mockJellyfinServerDao.addOrUpdateServer(server) } just Runs every { mockApiClient.clientInfo } returns ClientInfo("Wholphin test", "0.0.1") every { mockApiClient.deviceInfo } returns DeviceInfo("Wholphin test ID", "Wholphin test device") every { mockApiClient.update(any(), any(), any(), any()) } just Runs + coEvery { mockMostRecentServerProvider.save(any()) } just Runs + coEvery { mockMostRecentServerProvider.clear() } just Runs + coEvery { mockMostRecentServerProvider.clearUser() } just Runs } @OptIn(ExperimentalCoroutinesApi::class) @@ -119,8 +129,8 @@ class ServerRepositoryTest { mockJellyfin, mockJellyfinServerDao, mockApiClient, - dataStore, testDispatcher, + mockMostRecentServerProvider, ) private val serverId = UUID.randomUUID() @@ -194,24 +204,24 @@ class ServerRepositoryTest { Assert.assertEquals(user, serverRepository.currentUser) Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) - val appPreferences = dataStore.data.first() - Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) - Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) - - verify(exactly = 1) { mockSharedPreferences.edit() } + coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } } @Test fun `Test restoreUser via changeUser`() = runTest { + val user = user.copy(pin = null) every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) val serverRepository = create() Assert.assertNull(serverRepository.currentUser) - serverRepository.restoreSession(server.id, user.id) + val result = serverRepository.tryChangeUser(server.id, user.id) + assertTrue(result != null) + assertEquals(serverId, result?.server?.id) + assertEquals(user.id, result?.user?.id) verify(exactly = 1) { mockApiClient.update( @@ -226,34 +236,6 @@ class ServerRepositoryTest { Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) } - @Test - fun `Test restoreUser via shortcut`() = - runTest { - every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) - - val serverRepository = create() - setUpCurrentUser(serverRepository) - - serverRepository.restoreSession(server.id, user.id) - - verify(exactly = 1) { - mockApiClient.update( - baseUrl = server.url, - accessToken = user.accessToken, - clientInfo = any(), - deviceInfo = any(), - ) - } - verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateServer(any()) } - verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateUser(any()) } - - Assert.assertEquals(server, serverRepository.currentServer) - Assert.assertEquals(user, serverRepository.currentUser) - - // TODO fix this, need to check userDto too -// Assert.assertEquals(userDto, serverRepository.currentUserDto) - } - @Test fun `Test remove user`() = runTest { @@ -308,6 +290,7 @@ class ServerRepositoryTest { ) } verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + coVerify { mockMostRecentServerProvider.clearUser() } } @Test @@ -351,6 +334,7 @@ class ServerRepositoryTest { ) } verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) } + coVerify { mockMostRecentServerProvider.clear() } } @Test @@ -387,11 +371,7 @@ class ServerRepositoryTest { Assert.assertEquals(user, serverRepository.currentUser) Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) - val appPreferences = dataStore.data.first() - Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) - Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) - - verify(exactly = 1) { mockSharedPreferences.edit() } + coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } } @Test @@ -430,11 +410,7 @@ class ServerRepositoryTest { Assert.assertEquals(user, serverRepository.currentUser) Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) - val appPreferences = dataStore.data.first() - Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) - Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) - - verify(exactly = 1) { mockSharedPreferences.edit() } + coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } } @Test @@ -484,4 +460,99 @@ class ServerRepositoryTest { } } } + + @Test + fun `Test restoreLastSession, success`() = + runTest { + val user = user.copy(pin = null) + coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + coEvery { mockJellyfinServerDao.getServer(serverId) } returns + JellyfinServerUsers(server, listOf(user)) + every { mockMostRecentServerProvider.get() } returns + MostRecentServer.from(CurrentUser(server, user)) + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.Success) + val current = (result as RestoredSession.Success).currentUser + assertEquals(server, current.server) + assertEquals(user, current.user) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + coVerify { mockMostRecentServerProvider.save(any()) } + } + + @Test + fun `Test restoreLastSession, user protected`() = + runTest { + every { mockMostRecentServerProvider.get() } returns + MostRecentServer.ServerAndUser(serverId, server.url, userId, "token") + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.ServerOnly) + val current = (result as RestoredSession.ServerOnly) + assertEquals(server, current.server) + } + + @Test + fun `Test restoreLastSession, server only`() = + runTest { + every { mockMostRecentServerProvider.get() } returns + MostRecentServer.Server(server.id, server.url) + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.ServerOnly) + val current = (result as RestoredSession.ServerOnly) + assertEquals(server, current.server) + } + + @Test + fun `Test restoreLastSession, nothing to restore`() = + runTest { + every { mockMostRecentServerProvider.get() } returns MostRecentServer.None + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.None) + } +} + +class TestMostRecentServerProvider : MostRecentServerProvider { + var mostRecentServer: MostRecentServer = MostRecentServer.None + + override fun get(): MostRecentServer = mostRecentServer + + override suspend fun save(current: CurrentUser) { + mostRecentServer = + if (current.user.accessToken.isNotNullOrBlank()) { + MostRecentServer.ServerAndUser( + current.server.id, + current.server.url, + current.user.id, + current.user.accessToken, + ) + } else { + MostRecentServer.Server( + current.server.id, + current.server.url, + ) + } + } + + override suspend fun clear() { + mostRecentServer = MostRecentServer.None + } + + override suspend fun clearUser() { + when (val s = mostRecentServer) { + MostRecentServer.None -> Unit + is MostRecentServer.Server -> Unit + is MostRecentServer.ServerAndUser -> MostRecentServer.Server(s.serverId, s.serverUrl) + } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt index b300e09a..d8304897 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt @@ -1,10 +1,12 @@ package com.github.damontecres.wholphin.services -import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ActivationFlag import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences @@ -523,6 +525,54 @@ class TestStreamChoiceServiceOnlyForced( ), streamAudioLang = "eng", ), + TestInput( + 0, + SubtitlePlaybackMode.NONE, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + null, + SubtitlePlaybackMode.NONE, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), ) } } @@ -885,7 +935,7 @@ data class TestInput( val streamAudioLang: String? = "eng", val subtitles: List, val itemPlayback: ItemPlayback? = null, - val plc: PlaybackLanguageChoice? = null, + val plc: List = emptyList(), val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE, val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE, val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, @@ -895,7 +945,7 @@ data class TestInput( private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)" -private fun serverRepo( +fun mockServerRepo( audioLang: String?, subtitleMode: SubtitlePlaybackMode?, subtitleLang: String?, @@ -918,15 +968,15 @@ private fun serverRepo( private fun runTest(input: TestInput) { val service = StreamChoiceService( - serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), - mockk(), + mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), + mockk(), ) val result = service.chooseSubtitleStream( audioStreamLang = input.streamAudioLang, candidates = input.subtitles, itemPlayback = input.itemPlayback, - playbackLanguageChoice = input.plc, + stc = input.plc, prefs = UserPreferences( AppPreferences.getDefaultInstance(), @@ -937,7 +987,7 @@ private fun runTest(input: TestInput) { ), ), ) - Assert.assertEquals(input.expectedIndex, result?.index) + Assert.assertEquals(input.expectedIndex, result.stream?.index) } fun subtitle( @@ -945,6 +995,8 @@ fun subtitle( lang: String?, default: Boolean = false, forced: Boolean = false, + sdh: Boolean = false, + external: Boolean = false, title: String? = null, ): MediaStream = MediaStream( @@ -952,6 +1004,26 @@ fun subtitle( language = lang, isDefault = default, isForced = forced, + isHearingImpaired = sdh, + isInterlaced = false, + index = index, + isExternal = external, + isTextSubtitleStream = true, + supportsExternalStream = true, + title = title, + ) + +fun audio( + index: Int, + lang: String?, + default: Boolean = false, + title: String? = null, +): MediaStream = + MediaStream( + type = MediaStreamType.AUDIO, + language = lang, + isDefault = default, + isForced = false, isHearingImpaired = false, isInterlaced = false, index = index, @@ -978,11 +1050,63 @@ private fun plc( audioLang: String? = null, subtitleLang: String? = null, subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null, -): PlaybackLanguageChoice = - PlaybackLanguageChoice( - userId = 1, - seriesId = UUID.randomUUID(), - audioLanguage = audioLang, - subtitleLanguage = subtitleLang, - subtitlesDisabled = subtitlesDisabled, - ) + subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null, +): List = + buildList { + audioLang + ?.let { + SeriesTrackChoice( + userId = 1, + language = audioLang, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ) + }?.let(::add) + + if (subtitleLang != null) { + SeriesTrackChoice( + userId = 1, + language = subtitleLang, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } else if (subtitlesDisabled == true) { + SeriesTrackChoice( + userId = 1, + language = null, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } else if (subtitlesOnlyForced == true) { + SeriesTrackChoice( + userId = 1, + language = null, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } + } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt index e066e837..0e5e0eba 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -5,8 +5,10 @@ import androidx.work.Data import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.test.currentUser import com.github.damontecres.wholphin.util.GetItemsRequestHandler import io.mockk.coEvery import io.mockk.coVerify @@ -113,16 +115,16 @@ class SuggestionsWorkerTest { val mockUser = mockk() var restored = false every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) } - coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers { + coEvery { mockServerRepository.restoreLastSession() } coAnswers { restored = true - mockUser + RestoredSession.Success(currentUser()) } every { mockPreferences.data } returns flowOf(mockPrefs()) coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult() val result = createWorker().doWork() - coVerify { mockServerRepository.restoreSession(testServerId, testUserId) } + coVerify { mockServerRepository.restoreLastSession() } assertEquals(ListenableWorker.Result.success(), result) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt new file mode 100644 index 00000000..e38479f0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt @@ -0,0 +1,85 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.HdrOverrideMode +import com.github.damontecres.wholphin.preferences.PlaybackOverrides +import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor +import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides +import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType +import org.junit.Assert +import org.junit.Test + +class TestHdrFormat { + private fun appPreferences(block: PlaybackOverrides.Builder.() -> Unit): AppPreferences = + AppPreferences.getDefaultInstance().updatePlaybackOverrides(block) + + @Test + fun `Test all auto returns empty sets`() { + val prefs = appPreferences { } + Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true)) + Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true)) + } + + @Test + fun `Test HDR10 override`() { + val prefs = appPreferences { hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.HDR10.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true)) + } + + @Test + fun `Test Dolby Vision Profile 5 override`() { + val prefs = appPreferences { doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test Dolby Vision Profile 7 override requires Jellyfin 10_11`() { + val prefs = appPreferences { doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE } + Assert.assertEquals( + emptySet(), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = false), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test Dolby Vision Profile 8 override`() { + val prefs = appPreferences { doviProfile8Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = false), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName, WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test multiple formats set to the same mode are unioned`() { + val prefs = + appPreferences { + hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE + hdr10PlusOverride = HdrOverrideMode.HDR_OVERRIDE_ENABLE + doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE + } + Assert.assertEquals( + setOf(WholphinVideoRangeType.HDR10.serialName, WholphinVideoRangeType.HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index e8c2fed1..a6fe6e2a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -1,7 +1,9 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.data.model.HomeGetItemsFilter import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.KeyValueEntry import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.ui.AspectRatio @@ -13,7 +15,6 @@ import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.junit.Assert import org.junit.Test import kotlin.reflect.KClass @@ -61,8 +62,8 @@ class TestHomeRowSamples { ), HomeRowConfig.GetItems( name = "Episodes by date created", - getItems = - GetItemsRequest( + filter = + HomeGetItemsFilter( parentId = UUID.randomUUID(), recursive = true, isFavorite = true, @@ -77,6 +78,12 @@ class TestHomeRowSamples { HomeRowConfig.TvPrograms(), HomeRowConfig.TvChannels(), HomeRowConfig.Suggestions(parentId = UUID.randomUUID()), + HomeRowConfig.CustomEndpoint( + endpoint = "/path/to/items", + title = "Items", + query = listOf(KeyValueEntry("key", "value")), + headers = listOf(KeyValueEntry("X-Header", "1234")), + ), ) } @@ -100,6 +107,7 @@ class TestHomeRowSamples { is HomeRowConfig.Suggestions -> foundTypes.add(it::class) is HomeRowConfig.TvChannels -> foundTypes.add(it::class) is HomeRowConfig.Studios -> foundTypes.add(it::class) + is HomeRowConfig.CustomEndpoint -> foundTypes.add(it::class) } } Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size) @@ -126,12 +134,10 @@ class TestHomeRowSamples { context = mockk(), api = mockk(), serverRepository = mockk(), - userPreferencesService = mockk(), navDrawerService = mockk(), - latestNextUpService = mockk(), - imageUrlService = mockk(), - suggestionService = mockk(), displayPreferencesService = mockk(), + serverPluginApi = mockk(), + serverDao = mockk(), ) val str = """{ @@ -149,7 +155,7 @@ class TestHomeRowSamples { ] }""" - val jsonElement = service.jsonParser.parseToJsonElement(str) + val jsonElement = HomeSettingsService.jsonParser.parseToJsonElement(str) val settings = service.decode(jsonElement) Assert.assertEquals(1, settings.rows.size) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt new file mode 100644 index 00000000..e58d8aec --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt @@ -0,0 +1,534 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao +import com.github.damontecres.wholphin.data.model.ActivationFlag +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.TrackChoiceParentType +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.audio +import com.github.damontecres.wholphin.services.mockServerRepo +import com.github.damontecres.wholphin.services.subtitle +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TestSeriesTrackChoice { + private val mockServerRepository = + mockServerRepo( + audioLang = "eng", + subtitleLang = "eng", + subtitleMode = SubtitlePlaybackMode.DEFAULT, + ) + private val mockSeriesTrackChoiceDao = mockk(relaxed = false) + + private val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + private val seriesId = UUID.randomUUID() + private val season1Id = UUID.randomUUID() + private val season2Id = UUID.randomUUID() + private val season3Id = UUID.randomUUID() + + private val user = + JellyfinUser( + 1, + UUID.randomUUID(), + null, + UUID.randomUUID(), + null, + ) + + @Before + fun before() { + every { mockServerRepository.currentUser } returns user + } + + @Test + fun `Test StreamChoiceService call correct dao function`() = + runTest { + val mockSeriesTrackChoiceDao = mockk(relaxed = true) + val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + val seriesId = UUID.randomUUID() + val seasonId = UUID.randomUUID() + + // Has both series & season + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = seriesId, + parentId = seasonId, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.get(any(), seriesId = seriesId, seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + + // Has only series + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = seriesId, + parentId = null, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.getBySeriesId(any(), seriesId = seriesId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + + // Has only season + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = null, + parentId = seasonId, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.getBySeasonId(any(), seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + } + + @Test + fun `Test series disabled`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNull(result.stream) + } + + @Test + fun `Test series only forced`() = + runTest { + val mockServerRepository = + mockServerRepo( + audioLang = "eng", + subtitleLang = "eng", + subtitleMode = SubtitlePlaybackMode.NONE, + ) + val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = + UserPreferences( + AppPreferences.getDefaultInstance(), + JellyfinUserPreferences(), + ), + candidates = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "eng", forced = false), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + ), + ), + ) + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test series disabled with season`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNull(result.stream) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test item playback overrides STC for subtitles`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + subtitleIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Item) + } + + @Test + fun `Test item playback overrides STC for audio`() = + runTest { + val result = + streamChoiceService.chooseAudioStream( + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + audioIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + audio(0, "eng", default = true), + audio(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.AUDIO, + language = "eng", + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.AUDIO, + language = "eng", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Item) + } + + @Test + fun `Test season STC doesn't apply but series STC does - subtitle`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + audioIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + language = "spa", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test season STC doesn't apply but series STC does - audio`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + audio(0, "eng", default = true), + audio(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ONLY_FORCED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ACTIVATED, + language = "spa", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test STC has forced flag`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = listOf(TrackFlag.FORCED).calculateFlag(), + ), + ) + + // Choose forced track + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "spa"), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // No forced track to choose from, so nothing should be chosen + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = false, forced = false), + subtitle(1, "spa"), + ), + stc = stc, + ).let { result -> + assertNull(result.stream) + } + } + + @Test + fun `Test prefer SDH`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = listOf(TrackFlag.SDH).calculateFlag(), + ), + ) + + // Prefer SDH over default and non-SDH + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false, sdh = false), + subtitle(2, "eng", default = false, forced = false, sdh = true), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(2, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // No SDH, so prefer regular over forced + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false, sdh = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + } + + @Test + fun `Test prefer default track with no other flags`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + ), + ) + + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = false), + subtitle(1, "eng", default = false, forced = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // Test out-of-order tracks + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = false, forced = false), + subtitle(1, "eng", default = true, forced = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt new file mode 100644 index 00000000..d2013f19 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt @@ -0,0 +1,44 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.EnumSet + +class TestTrackFlag { + @Test + fun basicTest() { + val flag = listOf(TrackFlag.DEFAULT, TrackFlag.EXTERNAL).calculateFlag() + assertTrue(TrackFlag.DEFAULT.within(flag)) + assertTrue(TrackFlag.EXTERNAL.within(flag)) + assertFalse(TrackFlag.FORCED.within(flag)) + assertFalse(TrackFlag.SDH.within(flag)) + } + + @Test + fun none() { + val flag = EnumSet.noneOf(TrackFlag::class.java).calculateFlag() + assertFalse(TrackFlag.DEFAULT.within(flag)) + assertFalse(TrackFlag.EXTERNAL.within(flag)) + assertFalse(TrackFlag.FORCED.within(flag)) + assertFalse(TrackFlag.SDH.within(flag)) + } + + @Test + fun all() { + val flag = EnumSet.allOf(TrackFlag::class.java).calculateFlag() + TrackFlag.entries.forEach { assertTrue(it.toString(), it.within(flag)) } + } + + @Test + fun checkFlags() { + TrackFlag.entries.forEach { + assertTrue("$it is not a power of two", it.flag > 0 && (it.flag and (it.flag - 1)) == 0) + } + val distinctFlags = TrackFlag.entries.map { it.flag }.distinct() + assertEquals(TrackFlag.entries.size, distinctFlags.size) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index 34d962da..c291d067 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -11,7 +11,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.NameGuidPair import org.jellyfin.sdk.model.api.UserDto -import org.junit.Assert.assertNotNull import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -149,3 +148,16 @@ inline fun assertIs(obj: Any?) { Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ") } } + +/** + * Asserts that the object is not null + * + * Includes a [contract] to smart-cast the object to T + */ +@OptIn(ExperimentalContracts::class) +fun assertNotNull(obj: Any?) { + contract { + returns() implies (obj != null) + } + org.junit.Assert.assertNotNull(obj) +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index c4a54ec2..b266bb96 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -17,15 +17,18 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.MostRecentServerProvider import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.services.TestMostRecentServerProvider import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.DatabaseModule @@ -201,6 +204,10 @@ object TestModule { fun seerrApi( @StandardOkHttpClient okHttpClient: OkHttpClient, ): SeerrApi = mockk() + + @Provides + @Singleton + fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider() } @Module @@ -260,6 +267,10 @@ object TestDatabaseModule { @Singleton fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides + @Singleton + fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt index bc879d24..ba7f1e14 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.services.DeletedItem import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.ui.successQueryResult import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler @@ -34,6 +35,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.junit.After @@ -57,6 +59,7 @@ class CollectionFolderViewModelTest { private val mockUserLibraryApi = mockk() private val artistsRequest = slot() + private val albumArtistsRequest = slot() private val itemsRequest = slot() private val libraryId = UUID.randomUUID() @@ -83,11 +86,14 @@ class CollectionFolderViewModelTest { // against the relaxed ApiClient mockkObject(GetItemsRequestHandler) mockkObject(GetArtistsHandler) + mockkObject(GetAlbumArtistsHandler) mockkObject(GetPersonsHandler) coEvery { GetItemsRequestHandler.execute(mockApi, capture(itemsRequest)) } returns successQueryResult(totalRecordCount = LIBRARY_ITEM_COUNT) coEvery { GetArtistsHandler.execute(mockApi, capture(artistsRequest)) } returns successQueryResult(totalRecordCount = ARTIST_COUNT) + coEvery { GetAlbumArtistsHandler.execute(mockApi, capture(albumArtistsRequest)) } returns + successQueryResult(totalRecordCount = ALBUM_ARTIST_COUNT) coEvery { GetPersonsHandler.execute(mockApi, any()) } returns successQueryResult(totalRecordCount = PERSON_COUNT) } @@ -97,33 +103,36 @@ class CollectionFolderViewModelTest { WholphinDispatchers.reset() unmockkObject(GetItemsRequestHandler) unmockkObject(GetArtistsHandler) + unmockkObject(GetAlbumArtistsHandler) unmockkObject(GetPersonsHandler) } - private fun createViewModel(filter: GetItemsFilter) = - CollectionFolderViewModel( - savedStateHandle = SavedStateHandle(), - api = mockApi, - context = mockk(relaxed = true), - serverRepository = mockServerRepository, - libraryDisplayInfoDao = mockk(relaxed = true), - favoriteWatchManager = mockk(relaxed = true), - backdropService = mockk(relaxed = true), - navigationManager = mockk(relaxed = true), - themeSongPlayer = mockk(relaxed = true), - userPreferencesService = mockk(relaxed = true), - mediaManagementService = mockMediaManagementService, - musicService = mockk(relaxed = true), - streamChoiceService = mockk(relaxed = true), - serverReportService = mockk(relaxed = true), - filterOptionCache = mockk(relaxed = true), - itemId = libraryId.toString(), - initialSortAndDirection = null, - recursive = true, - collectionFilter = CollectionFolderFilter(filter = filter), - useSeriesForPrimary = false, - defaultViewOptions = ViewOptionsSquare, - ) + private fun createViewModel( + filter: GetItemsFilter, + viewOptions: ViewOptions = ViewOptionsSquare, + ) = CollectionFolderViewModel( + savedStateHandle = SavedStateHandle(), + api = mockApi, + context = mockk(relaxed = true), + serverRepository = mockServerRepository, + libraryDisplayInfoDao = mockk(relaxed = true), + favoriteWatchManager = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + navigationManager = mockk(relaxed = true), + themeSongPlayer = mockk(relaxed = true), + userPreferencesService = mockk(relaxed = true), + mediaManagementService = mockMediaManagementService, + musicService = mockk(relaxed = true), + streamChoiceService = mockk(relaxed = true), + serverReportService = mockk(relaxed = true), + filterOptionCache = mockk(relaxed = true), + itemId = libraryId.toString(), + initialSortAndDirection = null, + recursive = true, + collectionFilter = CollectionFolderFilter(filter = filter), + useSeriesForPrimary = false, + defaultViewOptions = viewOptions, + ) /** Counting via /Items would return every song and album, overshooting the artist grid */ @Test @@ -143,6 +152,37 @@ class CollectionFolderViewModelTest { coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any()) } } + @Test + fun `positionOfLetter counts album artists for the ALBUM_ARTIST source`() = + runTest(testDispatcher) { + val viewModel = + createViewModel( + GetItemsFilter(override = GetItemsFilterOverride.ARTIST), + ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)), + ) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(ALBUM_ARTIST_COUNT, position) + assertEquals("K", albumArtistsRequest.captured.nameLessThan) + assertEquals(libraryId, albumArtistsRequest.captured.parentId) + coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any()) } + } + + @Test + fun `changing the artist source reloads from the album artists endpoint`() = + runTest(testDispatcher) { + val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST)) + advanceUntilIdle() + coVerify(exactly = 0) { GetAlbumArtistsHandler.execute(any(), any()) } + + viewModel.saveViewOptions(ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST))) + advanceUntilIdle() + + coVerify { GetAlbumArtistsHandler.execute(any(), any()) } + } + /** * Without SortName, the alphabet highlight falls back to Name and highlights "T" for artists * like "The Beatles" even though the grid sorts them under "B". @@ -197,6 +237,7 @@ class CollectionFolderViewModelTest { companion object { private const val LIBRARY_ITEM_COUNT = 7315 private const val ARTIST_COUNT = 730 + private const val ALBUM_ARTIST_COUNT = 215 private const val PERSON_COUNT = 12 } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt new file mode 100644 index 00000000..511dbaa2 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt @@ -0,0 +1,276 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PlaylistCreationResult +import com.github.damontecres.wholphin.services.PlaylistCreator +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.test.TestTracks +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.test.playlist +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.ExternalPlayerApp +import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.client.extensions.subtitleApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.PlayStateApi +import org.jellyfin.sdk.api.operations.SubtitleApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.extensions.inWholeTicks +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.minutes + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class PlayExternalViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val context = mockk() + private val api = mockk() + private val userLibraryApi = mockk() + private val videosApi = mockk() + private val subtitleApi = mockk() + private val playStateApi = mockk(relaxed = true) + private val serverRepository = mockk() + private val itemPlaybackDao = mockk() + private val playlistCreator = mockk() + private val streamChoiceService = mockk() + private val navigationManager = mockk(relaxed = true) + private val userPreferencesService = mockk() + + private val movie = movie() + private val subtitleUrl = "http://localhost:8096/subtitles/0.srt" + private val videoUrl = "http://localhost:8096/video/stream" + private val mediaSource = + TestTracks + .Builder() + .addExternalSubtitle() + .buildMediaSourceInfo() + .let { source -> + source.copy( + id = "source-id", + runTimeTicks = 90.minutes.inWholeTicks, + mediaStreams = + source.mediaStreams!!.map { + it.copy(codec = "srt", path = "subtitle.srt", displayTitle = "English") + }, + ) + } + private val subtitle = mediaSource.mediaStreams!!.single() + private val vlc = ComponentName("org.videolan.vlc", "org.videolan.vlc.gui.video.VideoPlayerActivity") + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + mockkStatic(::getExternalPlayers) + every { getExternalPlayers(context) } returns + listOf(ExternalPlayerApp("VLC", null, vlc.flattenToString())) + every { api.userLibraryApi } returns userLibraryApi + every { api.videosApi } returns videosApi + every { api.subtitleApi } returns subtitleApi + every { api.playStateApi } returns playStateApi + every { serverRepository.currentUser } returns null + coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource + coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns + StreamChoiceResult(subtitle, StreamChoiceReason.Unknown) + coEvery { streamChoiceService.getSeriesTrackChoices(any(), SeriesTrackChoiceType.SUBTITLE) } returns emptyList() + every { + videosApi.getVideoStreamUrl( + itemId = movie.id, + mediaSourceId = mediaSource.id, + static = true, + ) + } returns videoUrl + every { + subtitleApi.getSubtitleUrl( + routeItemId = any(), + routeMediaSourceId = mediaSource.id!!, + routeIndex = subtitle.index, + routeFormat = "srt", + ) + } returns subtitleUrl + setupPlayer("") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + unmockkStatic(::getExternalPlayers) + WholphinDispatchers.reset() + } + + private fun setupPlayer(playerId: String) { + val preferences = + AppPreferences.getDefaultInstance().updatePlaybackPreferences { + externalPlayer = playerId + } + coEvery { userPreferencesService.getCurrent() } returns UserPreferences(preferences, null) + } + + private fun createIntent(destination: Destination): Intent { + val viewModel = + PlayExternalViewModel( + SavedStateHandle(), + context, + api, + serverRepository, + itemPlaybackDao, + playlistCreator, + streamChoiceService, + navigationManager, + userPreferencesService, + ) + viewModel.init(destination) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(LoadingState.Success, viewModel.state.value.loading) + return viewModel.state.value.intent + } + + private fun assertStartFromBeginning(intent: Intent) { + assertEquals(true, intent.extras!!.get("from_start")) + assertEquals(0, intent.extras!!.get("position")) + } + + @Test + fun `Explicit VLC starts from beginning at zero position`() = + runTest(testDispatcher) { + setupPlayer(vlc.flattenToString()) + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertEquals(vlc, intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Implicit player starts VLC from beginning at zero position`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertNull(intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Unavailable selected player falls back to implicit start from beginning`() = + runTest(testDispatcher) { + setupPlayer("missing.player/missing.player.Activity") + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertNull(intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Explicit VLC preserves positive resume position`() = + runTest(testDispatcher) { + setupPlayer(vlc.flattenToString()) + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + assertEquals(vlc, intent.component) + assertEquals(false, intent.extras!!.get("from_start")) + assertEquals(42_000, intent.extras!!.get("position")) + } + + @Test + fun `Implicit player preserves positive resume position`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + assertNull(intent.component) + assertEquals(false, intent.extras!!.get("from_start")) + assertEquals(42_000, intent.extras!!.get("position")) + } + + @Test + fun `Playback list starts from beginning`() = + runTest(testDispatcher) { + val playlist = playlist() + coEvery { userLibraryApi.getItem(playlist.id) } returns successResponse(playlist) + coEvery { playlistCreator.createFrom(any(), any(), any(), any(), any(), any()) } returns + PlaylistCreationResult.Success(Playlist.fromMedia(listOf(BaseItem(movie)))) + + val intent = createIntent(Destination.PlaybackList(playlist.id)) + assertStartFromBeginning(intent) + } + + @Test + fun `VLC starts from beginning without subtitles or duration`() = + runTest(testDispatcher) { + coEvery { streamChoiceService.chooseSource(any(), any()) } returns + mediaSource.copy(mediaStreams = emptyList(), runTimeTicks = null) + coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns null + + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertStartFromBeginning(intent) + assertFalse(intent.hasExtra("subtitles_location")) + assertFalse(intent.hasExtra("extra_duration")) + assertFalse(intent.hasExtra("subs.enable")) + assertFalse(intent.hasExtra("forcedsrt")) + } + + @Test + fun `Existing player extras and playback-start reporting remain unchanged`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + val title = "${BaseItem(movie).title} ${BaseItem(movie).subtitleLong}" + val subtitleUri = Uri.parse(subtitleUrl) + assertEquals(Intent.ACTION_VIEW, intent.action) + assertEquals(Uri.parse(videoUrl), intent.data) + assertEquals("video/*", intent.type) + assertEquals(title, intent.getStringExtra("title")) + assertEquals(42_000, intent.extras!!.get("position")) + assertTrue(intent.getBooleanExtra("return_result", false)) + assertTrue(intent.getBooleanExtra("secure_uri", false)) + assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs")) + assertArrayEquals(arrayOf("English"), intent.getStringArrayExtra("subs.name")) + assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs.enable")) + assertEquals(subtitleUrl, intent.getStringExtra("subtitles_location")) + assertEquals(90.minutes.inWholeMilliseconds, intent.extras!!.get("extra_duration")) + assertEquals(42_000, intent.extras!!.get("startfrom")) + assertFalse(intent.getBooleanExtra("forceresume", true)) + assertEquals(title, intent.getStringExtra("forcename")) + assertEquals(subtitleUri, intent.getParcelableExtra("forcedsrt")) + coVerify(exactly = 1) { playStateApi.reportPlaybackStart(any()) } + coVerify(exactly = 0) { playStateApi.reportPlaybackStopped(any()) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index 21c6179b..87e5670a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences @@ -29,6 +30,8 @@ import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.test.TestTracks @@ -64,6 +67,7 @@ import org.jellyfin.sdk.api.operations.UserLibraryApi import org.jellyfin.sdk.api.operations.VideosApi import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult import org.jellyfin.sdk.model.api.MediaSegmentType @@ -201,7 +205,12 @@ class PlaybackViewModelTests { coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource - coEvery { mockStreamChoiceService.getPlaybackLanguageChoice(any()) } returns null + coEvery { + mockStreamChoiceService.getSeriesTrackChoices( + any(), + any(), + ) + } returns emptyList() coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns PlayerCreation(mockPlayer) every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true) @@ -214,18 +223,26 @@ class PlaybackViewModelTests { any(), any(), ) - } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO } + } returns + StreamChoiceResult( + mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }, + StreamChoiceReason.Unknown, + ) coEvery { mockStreamChoiceService.chooseSubtitleStream( + item = any(), source = mediaSource, - any(), - any(), - any(), - any(), - any(), + audioStream = any(), + itemPlayback = any(), + stc = any(), + prefs = any(), + ) + } returns + StreamChoiceResult( + mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE }, + StreamChoiceReason.Unknown, ) - } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE } } @OptIn(ExperimentalCoroutinesApi::class) @@ -275,6 +292,49 @@ class PlaybackViewModelTests { return viewModel } + @Test + fun `Direct play includes the opened live stream ID`() = + runTest(testDispatcher) { + setupPreferences { cinemaMode = false } + val liveStreamId = "opened-live-stream" + val liveSource = mediaSource.copy(liveStreamId = liveStreamId) + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns + successResponse( + PlaybackInfoResponse( + mediaSources = listOf(liveSource), + playSessionId = playSessionId, + ), + ) + every { + mockVideosApi.getVideoStreamUrl( + itemId = movie.id, + mediaSourceId = liveSource.id, + liveStreamId = liveSource.liveStreamId, + static = true, + tag = liveSource.eTag, + playSessionId = playSessionId, + ) + } returns videoStreamUrl + + createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any()) } + val playbackUri = mediaItem.captured.localConfiguration?.uri + Assert.assertEquals(videoStreamUrl, playbackUri.toString()) + verify(exactly = 1) { + mockVideosApi.getVideoStreamUrl( + itemId = movie.id, + mediaSourceId = liveSource.id, + liveStreamId = liveStreamId, + static = true, + tag = liveSource.eTag, + playSessionId = playSessionId, + ) + } + } + @Test fun `Play intro first`() = runTest(testDispatcher) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt new file mode 100644 index 00000000..483fa3ef --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt @@ -0,0 +1,86 @@ +package com.github.damontecres.wholphin.util + +import androidx.media3.common.DrmInitData +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class DolbyVisionProfile7ConversionTest { + @Test + fun recognisesProfile7InBothSampleEntryForms() { + assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.07.06"))) + assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvh1.07.06"))) + } + + @Test + fun doesNotMistakeOtherProfilesOrCodecsForProfile7() { + assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.08.06"))) + assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.05.06"))) + assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision(null))) + assertFalse( + DolbyVisionProfile7Conversion.isProfile7( + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .setCodecs("dvhe.07.06") + .build(), + ), + ) + } + + @Test + fun rewritesOnlyTheProfileOfTheCodecString() { + assertEquals("dvhe.08.06", DolbyVisionProfile7Conversion.toProfile8("dvhe.07.06")) + assertEquals("dvh1.08.09", DolbyVisionProfile7Conversion.toProfile8("dvh1.07.09")) + } + + @Test + fun advertisesAProfile7TrackAsProfile8AndKeepsTheRestOfTheFormat() { + val format = dolbyVision("dvhe.07.06") + + val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format) + + assertEquals("dvhe.08.06", advertised.codecs) + assertEquals(MimeTypes.VIDEO_DOLBY_VISION, advertised.sampleMimeType) + assertEquals(format.width, advertised.width) + assertEquals(format.height, advertised.height) + assertEquals(format.initializationData, advertised.initializationData) + } + + @Test + fun leavesTracksWhichAreNotProfile7AsTheSameInstance() { + val profile8 = dolbyVision("dvhe.08.06") + val hevc = Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H265).build() + + assertSame(profile8, DolbyVisionProfile7Conversion.advertiseAsProfile8(profile8)) + assertSame(hevc, DolbyVisionProfile7Conversion.advertiseAsProfile8(hevc)) + } + + @Test + fun leavesAnEncryptedProfile7TrackAlone() { + // Encrypted samples take the secure path, which the bitstream filter does not touch + val encrypted = + dolbyVision("dvhe.07.06") + .buildUpon() + .setDrmInitData(DrmInitData(DrmInitData.SchemeData(java.util.UUID.randomUUID(), MimeTypes.VIDEO_MP4, byteArrayOf(1)))) + .build() + + assertSame(encrypted, DolbyVisionProfile7Conversion.advertiseAsProfile8(encrypted)) + } + + private companion object { + fun dolbyVision(codecs: String?): Format = + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) + .setCodecs(codecs) + .setWidth(3840) + .setHeight(2160) + .setInitializationData(listOf(byteArrayOf(0x01, 0x02))) + .build() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt new file mode 100644 index 00000000..d888be15 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt @@ -0,0 +1,119 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.TrackOutput +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * The factory has to reach the video track's format through three layers of media3 forwarding, + * rewrite it there and nowhere else, and let sample data through untouched. + */ +class DolbyVisionProfile7ExtractorsFactoryTest { + private val videoTrack = mockk(relaxed = true) + private val audioTrack = mockk(relaxed = true) + private val output = + mockk(relaxed = true) { + every { track(VIDEO_ID, C.TRACK_TYPE_VIDEO) } returns videoTrack + every { track(AUDIO_ID, C.TRACK_TYPE_AUDIO) } returns audioTrack + } + private val extractor = mockk(relaxed = true) + private val delegate = + mockk { + every { createExtractors() } returns arrayOf(extractor) + } + + @Test + fun advertisesTheVideoTrackAsProfile8() { + val video = initialisedVideoTrack() + + video.format(dolbyVision("dvhe.07.06")) + + val published = slot() + verify { videoTrack.format(capture(published)) } + assertEquals("dvhe.08.06", published.captured.codecs) + } + + @Test + fun leavesOtherVideoFormatsAsTheyAre() { + val video = initialisedVideoTrack() + val hevc = + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .setCodecs("hvc1.2.4.L153") + .build() + + video.format(hevc) + + verify { videoTrack.format(hevc) } + } + + @Test + fun leavesTheAudioTrackUnwrapped() { + val wrapped = wrappedOutput() + + assertSame(audioTrack, wrapped.track(AUDIO_ID, C.TRACK_TYPE_AUDIO)) + } + + @Test + fun passesSampleDataStraightThrough() { + val video = initialisedVideoTrack() + val data = ParsableByteArray(byteArrayOf(1, 2, 3)) + + video.sampleData(data, 3) + video.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) + + verify { videoTrack.sampleData(data, 3) } + verify { videoTrack.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) } + } + + @Test + fun wrapsEveryExtractorOfTheDelegate() { + val another = mockk(relaxed = true) + every { delegate.createExtractors() } returns arrayOf(extractor, another) + + val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors() + + assertEquals(2, extractors.size) + extractors[1].init(output) + verify { another.init(any()) } + } + + private fun wrappedOutput(): ExtractorOutput { + val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors() + extractors.single().init(output) + val wrapped = slot() + verify { extractor.init(capture(wrapped)) } + return wrapped.captured + } + + private fun initialisedVideoTrack(): TrackOutput = wrappedOutput().track(VIDEO_ID, C.TRACK_TYPE_VIDEO) + + private companion object { + const val VIDEO_ID = 1 + const val AUDIO_ID = 2 + + fun dolbyVision(codecs: String): Format = + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) + .setCodecs(codecs) + .build() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt new file mode 100644 index 00000000..b5c6189c --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt @@ -0,0 +1,293 @@ +package com.github.damontecres.wholphin.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import java.nio.ByteBuffer + +/** + * The RPU rewrite itself is libdovi's and runs on a device; here it is a fake, and what is under + * test is everything around it: which access units are touched at all, that the enhancement layer + * goes and the rest stays contiguous, that a refused rewrite leaves the decoder's buffer exactly as + * it was, and that the filter keeps to the [BitstreamFilter] contract on a direct buffer. + */ +class DolbyVisionProfile7FilterTest { + @Test + fun dropsTheEnhancementLayerAndRewritesTheRpu() { + val filter = filterWith(FakeRewriter(profile = 7)) + val target = directBuffer(annexB(VPS, VCL, EL, EL, RPU)) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VPS, VCL, CONVERTED_RPU).toList(), target.bytes(0, size)) + } + + @Test + fun convertsAnRpuWhichArrivedWithoutAnEnhancementLayer() { + // A dual layer remux whose enhancement layer lives in Matroska block additions, which the + // extractor discards: the RPU still needs its rewrite for the profile 8 decoder + val filter = filterWith(FakeRewriter(profile = 7)) + val target = directBuffer(annexB(VCL, RPU)) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(0, size)) + } + + @Test + fun worksFromAnOffsetAndLeavesTheBytesAroundTheAccessUnitAlone() { + val filter = filterWith(FakeRewriter(profile = 7)) + val accessUnit = annexB(VCL, EL, RPU) + val prefix = byteArrayOf(0x7A, 0x7B) + val suffix = byteArrayOf(0x7C) + val target = directBuffer(prefix + accessUnit + suffix) + + val size = filter.filter(target, prefix.size, accessUnit.size) + + assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(prefix.size, size)) + assertEquals(prefix.toList(), target.bytes(0, prefix.size)) + assertEquals(suffix.toList(), target.bytes(prefix.size + accessUnit.size, suffix.size)) + } + + @Test + fun doesNotMoveThePositionOrLimitOfTheBuffer() { + val filter = filterWith(FakeRewriter(profile = 7)) + val target = directBuffer(annexB(VCL, EL, RPU)) + target.position(3) + target.limit(target.capacity() - 1) + + filter.filter(target, 0, target.capacity()) + + assertEquals(3, target.position()) + assertEquals(target.capacity() - 1, target.limit()) + } + + @Test + fun leavesAnAccessUnitWithoutAnRpuAlone() { + val rewriter = FakeRewriter(profile = 7) + val filter = filterWith(rewriter) + val accessUnit = annexB(VPS, VCL, EL) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + assertEquals(0, rewriter.conversions) + } + + @Test + fun leavesAnRpuOfAnotherProfileAlone() { + val rewriter = FakeRewriter(profile = 8) + val filter = filterWith(rewriter) + val accessUnit = annexB(VCL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + assertEquals(0, rewriter.conversions) + } + + @Test + fun leavesAnAccessUnitAloneWhenTheRewriteThrows() { + val filter = filterWith(FakeRewriter(profile = 7, throws = true)) + val accessUnit = annexB(VCL, EL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + } + + @Test + fun leavesAnAccessUnitAloneWhenTheRewriteWouldNotFit() { + // No enhancement layer to make room, and an RPU which comes out longer than it went in + val filter = filterWith(FakeRewriter(profile = 7, converted = RPU + ByteArray(16) { 0x55 })) + val accessUnit = annexB(VCL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + } + + @Test + fun takesALongerRpuWhenTheEnhancementLayerMadeRoomForIt() { + val longerRpu = RPU + byteArrayOf(0x55, 0x56) + val filter = filterWith(FakeRewriter(profile = 7, converted = longerRpu)) + val target = directBuffer(annexB(VCL, EL, RPU)) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VCL, longerRpu).toList(), target.bytes(0, size)) + } + + @Test + fun leavesAnAccessUnitAloneWhenTheRewriteReportsNonsense() { + val filter = filterWith(FakeRewriter(profile = 7, reportedSize = -1)) + val accessUnit = annexB(VCL, EL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + } + + @Test + fun leavesDataWhichIsNotAnnexBAlone() { + val rewriter = FakeRewriter(profile = 7) + val filter = filterWith(rewriter) + val data = byteArrayOf(0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x01, 0x7C) + val target = directBuffer(data) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(data.size, size) + assertEquals(data.toList(), target.bytes(0, size)) + assertEquals(0, rewriter.conversions) + } + + @Test + fun walksThreeByteStartCodesToo() { + val filter = filterWith(FakeRewriter(profile = 7)) + val accessUnit = SHORT_START_CODE + VCL + SHORT_START_CODE + EL + SHORT_START_CODE + RPU + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals((SHORT_START_CODE + VCL + SHORT_START_CODE + CONVERTED_RPU).toList(), target.bytes(0, size)) + } + + @Test + fun givesUpForGoodWhenTheNativeLibraryIsMissing() { + var attempts = 0 + val filter = + DolbyVisionProfile7Filter { + attempts++ + throw UnsatisfiedLinkError("no libdovi_android for this ABI") + } + val accessUnit = annexB(VCL, EL, RPU) + val target = directBuffer(accessUnit) + + val first = filter.filter(target, 0, target.capacity()) + val second = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, first) + assertEquals(accessUnit.size, second) + assertEquals(accessUnit.toList(), target.bytes(0, accessUnit.size)) + assertEquals("the library should be tried once, not on every access unit", 1, attempts) + } + + @Test + fun handsTheRewriterTheAccessUnitWithoutTheEnhancementLayer() { + val rewriter = FakeRewriter(profile = 7) + val filter = filterWith(rewriter) + val target = directBuffer(annexB(VPS, VCL, EL, RPU, EL)) + + filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VPS, VCL, RPU).toList(), rewriter.lastFrame) + } + + /** + * Stands in for libdovi. It finds the RPU NAL unit the way the native code does, by walking the + * Annex B start codes, and swaps its payload for [converted], moving whatever follows. + */ + private class FakeRewriter( + private val profile: Int, + private val converted: ByteArray = CONVERTED_RPU, + private val throws: Boolean = false, + private val reportedSize: Int? = null, + ) : RpuRewriter { + var conversions = 0 + var lastFrame: List = emptyList() + + override fun profileOf( + frame: ByteBuffer, + size: Int, + ): Int { + assertFalse("libdovi reads the buffer's address, so the frame has to be direct", frame.hasArray()) + return profile + } + + override fun convertToProfile8( + frame: ByteBuffer, + size: Int, + ): Int { + conversions++ + lastFrame = frame.bytes(0, size) + if (throws) { + throw IllegalArgumentException("Unable to parse Dolby Vision RPU") + } + val bytes = frame.bytes(0, size).toByteArray() + val rpuStart = indexOfNal(bytes, RPU) + val rpuEnd = nextStartCode(bytes, rpuStart + 1) + val rewritten = bytes.copyOfRange(0, rpuStart) + converted + bytes.copyOfRange(rpuEnd, bytes.size) + check(rewritten.size <= frame.capacity()) { "the fake outgrew the scratch buffer" } + rewritten.forEachIndexed { index, byte -> frame.put(index, byte) } + return reportedSize ?: rewritten.size + } + + private fun indexOfNal( + bytes: ByteArray, + nal: ByteArray, + ): Int { + for (i in 0..bytes.size - nal.size) { + if (bytes.copyOfRange(i, i + nal.size).contentEquals(nal)) { + return i + } + } + throw AssertionError("RPU not found in the frame handed to the rewriter") + } + + private fun nextStartCode( + bytes: ByteArray, + from: Int, + ): Int { + for (i in from..bytes.size - 3) { + if (bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 1.toByte()) { + return if (i > from && bytes[i - 1] == 0.toByte()) i - 1 else i + } + } + return bytes.size + } + } + + companion object { + private val START_CODE = byteArrayOf(0, 0, 0, 1) + private val SHORT_START_CODE = byteArrayOf(0, 0, 1) + + /** NAL unit header for [type]: forbidden_zero_bit 0, nuh_layer_id 0, nuh_temporal_id_plus1 1. */ + private fun nal( + type: Int, + vararg payload: Byte, + ): ByteArray = byteArrayOf((type shl 1).toByte(), 0x01) + payload + + private val VPS = nal(32, 0x0C, 0x01, 0x7F) + private val VCL = nal(1, 0x11, 0x22, 0x33, 0x44) + private val EL = nal(63, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E) + private val RPU = nal(62, 0x19, 0x07, 0x07, 0x07) + private val CONVERTED_RPU = nal(62, 0x19, 0x08, 0x08) + + private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal } + + private fun filterWith(rewriter: RpuRewriter) = DolbyVisionProfile7Filter { rewriter } + + /** Like the decoder input buffer on a device: direct, with no backing array. */ + private fun directBuffer(data: ByteArray): ByteBuffer = + ByteBuffer.allocateDirect(data.size).also { buffer -> + data.forEachIndexed { index, byte -> buffer.put(index, byte) } + assertFalse(buffer.hasArray()) + } + + private fun ByteBuffer.bytes( + from: Int, + count: Int, + ): List = (from until from + count).map { get(it) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt new file mode 100644 index 00000000..491280df --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt @@ -0,0 +1,141 @@ +package com.github.damontecres.wholphin.util.profile + +import io.mockk.every +import io.mockk.mockk +import org.jellyfin.sdk.model.api.CodecType +import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.api.ProfileConditionValue +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Verifies that forceEnabledHdr/forceDisabledHdr correctly override the device's own + * codec-capability detection when building the unsupported video range type list. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class TestDeviceProfileUtils { + private fun unsupportedRangeTypes( + profile: DeviceProfile, + codec: String, + ): Set = + profile.codecProfiles + .filter { it.type == CodecType.VIDEO && it.codec == codec } + .firstNotNullOf { codecProfile -> + codecProfile.applyConditions.firstOrNull { it.property == ProfileConditionValue.VIDEO_RANGE_TYPE }?.value + }.split("|") + .toSet() + + private fun buildProfile( + mediaTest: MediaCodecCapabilitiesTest, + jellyfinTenEleven: Boolean = false, + decodeAv1: Boolean = false, + forceEnabledHdr: Set = emptySet(), + forceDisabledHdr: Set = emptySet(), + ): DeviceProfile = + createDeviceProfile( + mediaTest = mediaTest, + maxBitrate = 100_000_000, + isAC3Enabled = true, + downMixAudio = false, + assDirectPlay = true, + pgsDirectPlay = true, + decodeAv1 = decodeAv1, + jellyfinTenEleven = jellyfinTenEleven, + preferAc3ForSurround = false, + forceEnabledHdr = forceEnabledHdr, + forceDisabledHdr = forceDisabledHdr, + maxResolution = 0, + ) + + @Test + fun `Test HEVC Profile 7 unsupported when device does not support it`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns false + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = buildProfile(mediaTest, jellyfinTenEleven = true) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test force enable overrides HEVC Profile 7 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns false + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = + buildProfile( + mediaTest, + jellyfinTenEleven = true, + forceEnabledHdr = + setOf( + WholphinVideoRangeType.DOVI_WITH_EL.serialName, + WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName, + ), + ) + Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test force disable overrides HEVC Profile 7 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns true + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = + buildProfile( + mediaTest, + jellyfinTenEleven = true, + forceDisabledHdr = + setOf( + WholphinVideoRangeType.DOVI_WITH_EL.serialName, + WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName, + ), + ) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test AV1 Profile 5 unsupported when device does not support it`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns false + + val profile = buildProfile(mediaTest, decodeAv1 = false) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } + + @Test + fun `Test force enable overrides AV1 Profile 5 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns false + + val profile = + buildProfile( + mediaTest, + decodeAv1 = false, + forceEnabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName), + ) + Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } + + @Test + fun `Test force disable overrides AV1 Profile 5 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns true + + val profile = + buildProfile( + mediaTest, + decodeAv1 = false, + forceDisabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName), + ) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } +} diff --git a/docker/README.md b/docker/README.md index ec4e9d24..394aa9cd 100644 --- a/docker/README.md +++ b/docker/README.md @@ -27,6 +27,27 @@ Upstream enables JVM unit tests only for debug variants; there is no `testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor task without changing upstream's test configuration. +## Upstream v1.1.0 integration + +The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and +ExoplayerHdrUtils `0.4.1`. The pinned SDK/JDK image and serialized Gradle wrapper +remain compatible without changes; native Wholphin extensions remain `0.2.4`. +There are no committed dependency lockfiles or Gradle verification metadata. +Forgejo Actions stays disabled; upstream GitHub workflows remain dormant. + +The app database upgrades from Room schema 35 through 40 to 41 on first launch +after installation. The upstream migrations add series track choices and a home +settings source preference. Downgrading with the upgraded database is not +supported: a TV installation is a one-way step and requires explicit approval. +The optional companion plugin can supply Discover credentials before locally +saved Seerr profiles; do not configure an administrator API key in place of the +household's per-profile Jellyfin login, which Conductarr uses for request routing. + +Build only after committing a release merge: version names come from Git HEAD, +not the working tree. An uncommitted merge still builds with the old version name. +Neither an upstream merge nor its validation build authorizes a release or TV +installation. + ## Toolchain and disk use `Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 964b4534..9d1d45b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,23 +1,23 @@ [versions] -aboutLibraries = "15.2.0" -acra = "5.13.1" -agp = "9.2.1" +aboutLibraries = "15.3.0" +acra = "5.14.2" +agp = "9.5.0" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" hiltNavigationCompose = "1.4.0" hiltCompiler = "1.4.0" kache = "2.1.1" -kotlin = "2.4.20" +kotlin = "2.4.21" ksp = "2.3.9" -coreKtx = "1.19.0" +coreKtx = "1.19.1" appcompat = "1.8.0" composeBom = "2026.09.00" mockk = "1.14.11" profileinstaller = "1.4.1" robolectric = "4.17" -multiplatformMarkdownRenderer = "0.45.0" -okhttpBom = "5.5.0" +multiplatformMarkdownRenderer = "0.46.0" +okhttpBom = "5.5.1" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" @@ -26,15 +26,17 @@ tvMaterial = "1.1.0-alpha01" lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" androidx-media3 = "1.11.1" -coil = "3.6.2" +# Dolby Vision profile 7 -> 8.1 RPU rewrite (libdovi behind JNI), MIT +exoplayerhdrutils = "0.4.1" +coil = "3.6.3" jellyfin-sdk = "1.7.1" nav3Core = "1.1.7" lifecycleViewmodelNav3 = "2.11.0" material3AdaptiveNav3 = "1.3.0" protobuf = "0.10.0" datastore = "1.2.1" -kotlinx-serialization = "1.11.0" -protobuf-javalite = "4.36.1" +kotlinx-serialization = "1.12.0" +protobuf-javalite = "4.36.2" hilt = "2.60.1" room = "2.8.5" preferenceKtx = "1.2.1" @@ -44,7 +46,7 @@ paletteKtx = "1.0.0" libass-android = "0.5.1" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" -openapi-generator = "7.25.0" +openapi-generator = "7.26.0" runner = "1.7.0" wholphin-extensions = "0.2.4" junit = "4.13.2" @@ -118,6 +120,7 @@ androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-da androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" } +exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index b1b8ef56..9fa7f288 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a9db1155..91c4fc64 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew.bat b/gradlew.bat index 8508ef68..03754c4c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL%