diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 7cd04ce3..24711c4a 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@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.14' - name: Setup JDK - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 with: distribution: zulu java-version: '21' diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cafd4889..3bd2e54d 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mapping path: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e423cccf..9e518406 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@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.14' - name: Run pre-commit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b108ce5..4822ce7e 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: AAB path: | app/build/outputs/bundle/**/*.aab compression-level: 0 - name: Upload mapping files - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mapping path: | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 96883e40..6178f8ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -315,7 +315,6 @@ 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 b2a0c548..cddfb043 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -22,6 +22,3 @@ -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 deleted file mode 100644 index 3e874e4e..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json +++ /dev/null @@ -1,834 +0,0 @@ -{ - "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 deleted file mode 100644 index 4a41a052..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json +++ /dev/null @@ -1,841 +0,0 @@ -{ - "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 e024ca83..8a762a28 100644 --- a/app/src/appstore/AndroidManifest.xml +++ b/app/src/appstore/AndroidManifest.xml @@ -3,8 +3,5 @@ xmlns:tools="http://schemas.android.com/tools"> - diff --git a/app/src/firetv/AndroidManifest.xml b/app/src/firetv/AndroidManifest.xml deleted file mode 100644 index 8a762a28..00000000 --- a/app/src/firetv/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aad5c5b5..5be4d55a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,6 @@ 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 + } - try { - val result = handleIntent(intent) - if (result) { - Timber.i("appStart was handled by intent") - return@withLock + 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 + } } } catch (ex: Exception) { Timber.e(ex, "Error parsing intent") } try { - handleAppStart() + 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) + } + } } catch (ex: Exception) { Timber.e(ex, "Error during appStart") setupNavigationManager.navigateTo(SetupDestination.ServerList) @@ -488,103 +582,6 @@ 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 6ec9d4b4..f027bbae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -122,7 +122,5 @@ 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 3bc17f3c..57d4a447 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -22,7 +22,6 @@ 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 @@ -34,7 +33,9 @@ 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 @@ -74,10 +75,8 @@ class WholphinDreamService : lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleScope.launchDefault { if (serverRepository.current.value == null) { - val result = serverRepository.restoreLastSession() - if (result !is RestoredSession.Success) { - Timber.w("Could not restore user session") - } + val prefs = preferencesDataStore.data.first() + serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull()) } } } 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 45af2d65..6a691723 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,7 +19,6 @@ 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 @@ -49,9 +48,8 @@ import java.util.UUID SeerrServer::class, SeerrUser::class, RememberedTab::class, - SeriesTrackChoice::class, ], - version = 41, + version = 35, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -70,8 +68,6 @@ import java.util.UUID AutoMigration(32, 33), AutoMigration(33, 34), AutoMigration(34, 35), - AutoMigration(35, 40), - AutoMigration(40, 41), ], ) @TypeConverters(Converters::class) @@ -91,8 +87,6 @@ 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 702ecfdb..c3afbc33 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,12 +3,9 @@ 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.RelativeMediaStream -import com.github.damontecres.wholphin.data.model.SeriesTrackChoice -import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice 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 @@ -31,7 +28,7 @@ class ItemPlaybackRepository constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, - private val seriesTrackChoiceDao: SeriesTrackChoiceDao, + private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, private val streamChoiceService: StreamChoiceService, ) { /** @@ -48,79 +45,54 @@ class ItemPlaybackRepository ): ChosenStreams? = serverRepository.currentUser?.let { user -> val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) - return getChosenItemFromPlayback(item, itemPlayback, prefs) + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) + Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}") + return getChosenItemFromPlayback(item, itemPlayback, plc, 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] & [SeriesTrackChoice] or the default choices if they are null + * This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null */ fun getChosenItemFromPlayback( item: BaseItem, itemPlayback: ItemPlayback?, - audioStc: List, - subtitleStc: List, + plc: PlaybackLanguageChoice?, 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 audioResult = + val audioStream = streamChoiceService.chooseAudioStream( candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } .orEmpty(), itemPlayback = itemPlayback, - stc = audioStc, + playbackLanguageChoice = plc, prefs = prefs, ) val subtitleStream = streamChoiceService.chooseSubtitleStream( - audioStreamLang = audioResult.stream?.language, + audioStreamLang = audioStream?.language, candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty(), itemPlayback = itemPlayback, - stc = subtitleStc, + playbackLanguageChoice = plc, prefs = prefs, ) return ChosenStreams( itemPlayback = itemPlayback, - audioStc = audioStc, - subtitleStc = subtitleStc, + plc = plc, itemId = item.id, source = source, videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }, - audioStream = audioResult, + audioStream = audioStream, subtitleStream = subtitleStream, subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED, ) @@ -184,36 +156,26 @@ class ItemPlaybackRepository Timber.v("Saving track selection %s", toSave) toSave = saveItemPlayback(toSave) val seriesId = item.data.seriesId - if (seriesId != null || item.data.parentId != null) { + if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) { if (type == MediaStreamType.AUDIO) { - val stream = - RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) + val stream = source.mediaStreams?.first { it.index == trackIndex } if (stream?.language != null) { - streamChoiceService.saveSeriesTrackChoice( - item.data, - stream.type, - stream, - ) + streamChoiceService.updateAudio(item.data, stream.language!!) } } else if (type == MediaStreamType.SUBTITLE) { if (trackIndex == TrackIndex.DISABLED) { - streamChoiceService.saveDisabledSeriesTrackChoice( + streamChoiceService.updateSubtitles( item.data, - MediaStreamType.SUBTITLE, - ) - } else if (trackIndex == TrackIndex.ONLY_FORCED) { - streamChoiceService.saveOnlyForcedSeriesTrackChoice( - item.data, - MediaStreamType.SUBTITLE, + subtitleLang = null, + subtitlesDisabled = true, ) } else { - val stream = - RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) + val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex } if (stream?.language != null) { - streamChoiceService.saveSeriesTrackChoice( + streamChoiceService.updateSubtitles( item.data, - stream.type, - stream, + stream.language!!, + subtitlesDisabled = false, ) } } @@ -278,10 +240,9 @@ class ItemPlaybackRepository */ suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { Timber.d("deleteChosenStreams: %s", chosenStreams) - val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty() - if (stc.isNotEmpty()) { - Timber.d("Deleting %s", stc) - seriesTrackChoiceDao.delete(stc) + chosenStreams?.plc?.let { + Timber.d("Deleting %s", it) + playbackLanguageChoiceDao.delete(it) } chosenStreams?.itemPlayback?.let { Timber.d("Deleting %s", it) @@ -295,12 +256,11 @@ class ItemPlaybackRepository */ data class ChosenStreams( val itemPlayback: ItemPlayback?, - val audioStc: List, - val subtitleStc: List, + val plc: PlaybackLanguageChoice?, val itemId: UUID, val source: MediaSourceInfo, val videoStream: MediaStream?, - val audioStream: StreamChoiceResult, - val subtitleStream: StreamChoiceResult, + val audioStream: MediaStream?, + val subtitleStream: MediaStream?, 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 deleted file mode 100644 index 5139fb73..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt +++ /dev/null @@ -1,175 +0,0 @@ -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 06835381..5f8c16e6 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,27 +8,17 @@ 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 deleted file mode 100644 index b3126723..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt +++ /dev/null @@ -1,52 +0,0 @@ -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 2c24c8f3..5deba4c3 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,10 +1,15 @@ 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 @@ -44,8 +49,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 @@ -72,8 +77,6 @@ class ServerRepository } } - val serverPluginInstalled = MutableStateFlow(false) - /** * Adds a server to the app database and updated the [ApiClient] to the server's URL * @@ -85,11 +88,10 @@ 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, @@ -117,101 +119,70 @@ 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) - mostRecentServerProvider.save(currentUser) - _current.value = currentUser - _currentUserDto.value = ServerUserConfig(userDto) + 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) + } return@withContext currentUser } /** - * Try to restore the most recent session + * Restores a session for the given server & user such as when the app reopens * - * Does not check if auto sign-in is enabled, but does check if the user profile is protected + * If user has a PIN, this returns false */ - 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( + suspend fun restoreSession( serverId: UUID?, userId: UUID?, - ): CurrentUser? = - withContext(ioDispatcher) { - return@withContext if (serverId == null || userId == null) { - _current.value = null - null + ): 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 } else { - 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 + val user = serverAndUsers.users.firstOrNull { it.id == userId } + if (user != null) { + return changeUser(serverAndUsers.server, user) } } } + 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 @@ -273,7 +244,13 @@ class ServerRepository withContext(WholphinDispatchers.Main) { _current.value = null } - mostRecentServerProvider.clearUser() + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentUserId = "" + }.build() + } apiClient.update(accessToken = null) } withContext(ioDispatcher) { @@ -286,7 +263,14 @@ class ServerRepository withContext(WholphinDispatchers.Main) { _current.value = null } - mostRecentServerProvider.clear() + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentServerId = "" + currentUserId = "" + }.build() + } apiClient.update(baseUrl = null, accessToken = null) } withContext(ioDispatcher) { @@ -295,7 +279,14 @@ class ServerRepository } suspend fun switchServerOrUser() { - mostRecentServerProvider.clear() + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentServerId = "" + currentUserId = "" + }.build() + } } suspend fun updateUserAuth( @@ -344,6 +335,17 @@ 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 256ccd65..4b38d398 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,7 +66,6 @@ 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 91228969..3746092b 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,7 +9,6 @@ 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 @@ -156,22 +155,6 @@ 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 39b82bc8..4914248f 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,7 +1,4 @@ -@file:UseSerializers( - UUIDSerializer::class, - DateTimeSerializer::class, -) +@file:UseSerializers(UUIDSerializer::class) package com.github.damontecres.wholphin.data.model @@ -10,15 +7,11 @@ 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 @@ -181,45 +174,19 @@ sealed interface HomeRowConfig { } /** - * An arbitrary [HomeGetItemsFilter] allowing to query for most anything + * An arbitrary [GetItemsRequest] allowing to query for anything */ @Serializable @SerialName("GetItems") data class GetItems( val name: String, - val filter: HomeGetItemsFilter, + val getItems: GetItemsRequest, 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 * @@ -274,54 +241,3 @@ 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 6c5a2a58..4c54311c 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,7 +13,6 @@ 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 @@ -69,7 +68,6 @@ 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() @@ -79,17 +77,9 @@ 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, config=$config" - - fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config)) + "appPreferences=$appPreferences" } -@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 a20a2dcd..4e639009 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, tag = dto.primaryImageTag), + imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), 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, tag = dto.primaryImageTag), + imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), 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 f5507a50..fb54dfcd 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,14 +10,9 @@ 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 deleted file mode 100644 index cd2fece7..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt +++ /dev/null @@ -1,61 +0,0 @@ -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 918384d4..aafdebf7 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,11 +54,3 @@ 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 deleted file mode 100644 index 1ff7212b..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 5dd313d6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt +++ /dev/null @@ -1,199 +0,0 @@ -@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 63761d99..188b7020 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,7 +9,6 @@ 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 @@ -346,7 +345,7 @@ sealed interface AppPreference { const val MEGA_BIT = 1024 * 1024L const val DEFAULT_BITRATE = 100 * MEGA_BIT - val bitrateValues = + private val bitrateValues = listOf( 500 * 1024L, 750 * 1024L, @@ -379,7 +378,11 @@ sealed interface AppPreference { summarizer = { value -> if (value != null) { val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE - formatMaxBitrate(v) + if (v < MEGA_BIT) { + "${v / 1024} kbps" + } else { + "${v / MEGA_BIT} Mbps" + } } else { null } @@ -453,69 +456,15 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) - val Hdr10Override = - AppChoicePreference( - title = R.string.hdr10_override, - defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, - getter = { it.playbackPreferences.overrides.hdr10Override }, + val DirectPlayDoviProfile7 = + AppSwitchPreference( + title = R.string.force_dovi_profile_7, + defaultValue = false, + getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { hdr10Override = value } + prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = 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 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 }, + summary = R.string.force_dovi_profile_7_summary, ) val DecodeAv1 = @@ -970,12 +919,6 @@ 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, @@ -1240,17 +1183,8 @@ 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 = @@ -1261,14 +1195,6 @@ 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 85f8aade..cb47ca94 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,8 +186,6 @@ 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 46c75636..0647a900 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,17 +48,6 @@ 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, @@ -104,7 +93,6 @@ 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 deleted file mode 100644 index df0b6823..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt +++ /dev/null @@ -1,56 +0,0 @@ -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 17312378..4910110a 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,20 +6,14 @@ 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 @@ -42,7 +36,6 @@ 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 @@ -63,9 +56,6 @@ 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!!) @@ -452,59 +442,5 @@ 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 13ef5f70..b9b15f97 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,12 +4,8 @@ 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 @@ -65,22 +61,9 @@ 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 deleted file mode 100644 index fe462b98..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt +++ /dev/null @@ -1,765 +0,0 @@ -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 1c05640e..c070b37e 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,27 +2,48 @@ 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 @@ -34,15 +55,32 @@ 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 @@ -54,11 +92,21 @@ 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 */ @@ -140,8 +188,9 @@ 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) { @@ -162,42 +211,6 @@ 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] * @@ -205,70 +218,55 @@ class HomeSettingsService * * Does not persist either the server nor default */ - suspend fun loadCurrentSettings(user: JellyfinUser) { - currentSettings.update { HomePageResolvedSettings.EMPTY } - - Timber.v("Getting setting for %s", user.id) + suspend fun loadCurrentSettings(userId: UUID) { + Timber.v("Getting setting for %s", userId) + // User local then server/remote otherwise create a default val settings = try { - fetchSettingsFor(user.id, user.config.homeSettingsSource) - } catch (ex: CancellationException) { - throw ex + val local = loadFromLocal(userId) + Timber.v("Found local? %s", local != null) + local } catch (ex: Exception) { - Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource) + 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") null } - Timber.v("Found settings: %s", settings != null) - val resolvedSettings = settings ?: createDefault(user.id) + 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) + } + 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( - source: HomePageSettingsSource, + userId: UUID, settings: HomePageSettings, ) { val resolvedRows = settings.rows.mapIndexed { index, config -> resolve(index, config) } - currentSettings.update { it.copy(rows = resolvedRows) } - serverRepository.currentUser?.let { user -> - val toSave = user.updateConfig { it.copy(homeSettingsSource = source) } - serverDao.updateUser(toSave) - } + val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows) + currentSettings.update { resolvedSettings } } /** @@ -318,7 +316,7 @@ class HomeSettingsService ), ) val rowConfig = continueWatchingRow + includedIds - return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig) + return HomePageResolvedSettings(userId, rowConfig) } /** @@ -434,7 +432,7 @@ class HomeSettingsService null } }.flatten() - HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs) + HomePageResolvedSettings(userId, rowConfigs) } else { null } @@ -562,14 +560,6 @@ class HomeSettingsService config, ) } - - is HomeRowConfig.CustomEndpoint -> { - HomeRowConfigDisplay( - id = id, - title = StringStringProvider(config.title), - config, - ) - } } private suspend fun getItemName( @@ -595,16 +585,631 @@ 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 - } } } @@ -624,14 +1229,10 @@ 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(), HomePageSettingsSource.UNSET, emptyList()) + val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList()) } } @@ -665,6 +1266,17 @@ 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, @@ -703,14 +1315,3 @@ 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 967629da..6b71d683 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,11 +35,6 @@ 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 -> { @@ -49,7 +44,6 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, - tag = parentLogoTag.takeIf { parentLogoId == seriesId }, ) } else { getItemImageUrl( @@ -57,7 +51,6 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, - tag = imageTags[imageType], ) } } @@ -70,7 +63,6 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, - tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId }, ) } else if (backdropTags.isNotEmpty()) { getItemImageUrl( @@ -78,7 +70,6 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, - tag = backdropTags.first(), ) } else { null @@ -95,7 +86,6 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, - tag = parentThumbTag, ) } else if (useSeriesForPrimary && parentBackdropId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) @@ -106,7 +96,6 @@ class ImageUrlService imageType = ImageType.BACKDROP, fillWidth = fillWidth, fillHeight = fillHeight, - tag = parentBackdropTags.firstOrNull(), ) } else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { getItemImageUrl( @@ -114,7 +103,6 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, - tag = parentThumbTag, ) } else if (useSeriesForPrimary && parentThumbId == null && @@ -127,7 +115,6 @@ 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 @@ -136,7 +123,6 @@ class ImageUrlService imageType = ImageType.BACKDROP, fillWidth = fillWidth, fillHeight = fillHeight, - tag = backdropTags.first(), ) } else { getItemImageUrl( @@ -144,7 +130,6 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, - tag = imageTags[imageType], ) } } @@ -160,7 +145,6 @@ 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( @@ -168,7 +152,6 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, - tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY }, ) } else { getItemImageUrl( @@ -176,7 +159,6 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, - tag = imageTags[imageType], ) } } @@ -187,7 +169,6 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, - tag = imageTags[imageType], ) } } @@ -212,11 +193,6 @@ 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 4fda8260..2dd0cde5 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,8 +2,6 @@ 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 @@ -116,7 +114,6 @@ class IntentService return IntentResult.Target(destinations, true) } - @VisibleForTesting internal suspend fun prepare(intent: Intent): IntentResult? { val appPrefs = userPreferencesService.flow.first().appPreferences @@ -124,33 +121,39 @@ class IntentService val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull() return if (userId != null && serverId != null) { Timber.v("Intent switches user") - val user = serverRepository.tryChangeUser(serverId, userId) - if (user != null) { + val user = serverRepository.serverDao.getUser(serverId, userId) + if (user != null && !user.isProtected) { + serverRepository.restoreSession(serverId, userId) + ?: IntentResult.Error("Error restoring user") null } else { - IntentResult.Error("Error restoring user") + IntentResult.Error("Cannot switch to specified user") } - } 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") - } + } 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() - is RestoredSession.Success -> { - if (result.currentUser.user.isProtected) { + if (userId != null && serverId != null) { + val current = + serverRepository.restoreSession(serverId, userId) + ?: return IntentResult.Error("Error restoring user") + if (current.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 a60bf7c4..01ed9dd2 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,7 +1,6 @@ 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 @@ -14,7 +13,6 @@ 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 @@ -54,8 +52,12 @@ class LatestNextUpWorker try { if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { // Not active - val result = serverRepository.restoreLastSession() - if (result !is RestoredSession.Success) { + var currentUser = serverRepository.current.value + if (currentUser == null) { + serverRepository.restoreSession(serverId, userId) + currentUser = serverRepository.current.value + } + if (currentUser == null) { Timber.w("No user found during run") return Result.failure() } @@ -89,7 +91,7 @@ class LatestNextUpSchedulerService "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", ) - @VisibleForTesting + // Exposed for testing 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 16ac4edd..ec6e9d2c 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,11 +122,7 @@ class MusicService mutex.withLock { if (mediaSession == null) { Timber.i("Starting music MediaSession") - mediaSession = - MediaSession - .Builder(context, player) - .setCallback(TrustAllControllersCallback) - .build() + mediaSession = MediaSession.Builder(context, player).build() activityTracker = TrackActivityPlaybackListener(api, player) { state.value.currentItemId?.let { itemId -> @@ -331,7 +327,6 @@ 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 a2fd0eb7..9b16718d 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,7 +24,6 @@ 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 @@ -33,15 +32,9 @@ 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 @@ -64,7 +57,6 @@ class PlayerFactory constructor( @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, - private val deviceProfileService: DeviceProfileService, ) { @Volatile var currentPlayer: Player? = null @@ -102,16 +94,10 @@ class PlayerFactory val preferDolbyVision = appPreferences.experimentalPreferences .get { preferDolbyVisionOverHdr10Plus } ?: false - val convertDolbyVisionProfile7 = - DolbyVisionProfile7Conversion.isWanted( - appPreferences.experimentalPreferences, - deviceProfileService.mediaCodecCapabilitiesTest, - ) Timber.v( - "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s", + "extensions=%s, assPlaybackMode=%s", extensions, prefs.overrides.assPlaybackMode, - convertDolbyVisionProfile7, ) val rendererMode = when (extensions) { @@ -122,18 +108,9 @@ 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, - convertDolbyVisionProfile7, - ).setEnableDecoderFallback(true) + WholphinRenderersFactory(context, decodeAv1, preferDolbyVision) + .setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode) val mediaSourceFactory = @@ -149,16 +126,15 @@ class PlayerFactory renderersFactory = AssRenderersFactory(assHandler, renderersFactory) DefaultMediaSourceFactory( dataSourceFactory, - extractorsFactory - .withAssMkvSupport( - assSubtitleParserFactory, - assHandler, - ).forDolbyVision(), + extractorsFactory.withAssMkvSupport( + assSubtitleParserFactory, + assHandler, + ), ).setSubtitleParserFactory(assSubtitleParserFactory) } else { DefaultMediaSourceFactory( dataSourceFactory, - extractorsFactory.forDolbyVision(), + extractorsFactory, ) } val disableAudioOffload = @@ -184,9 +160,8 @@ class PlayerFactory AudioAttributes .Builder() .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) - .setUsage(C.USAGE_MEDIA) .build(), - true, + false, ) } } @@ -271,29 +246,9 @@ 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) { @@ -313,7 +268,6 @@ 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( @@ -326,16 +280,12 @@ 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 (bitstreamFilters.isNotEmpty()) { - BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters) + if (preferDolbyVisionOverHdr10Plus) { + BitstreamFilteringCodecAdapterFactory( + codecAdapterFactory, + listOf(Hdr10PlusMaskingFilter()), + ) } 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 0d0eb0b6..90d9526b 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,24 +9,20 @@ 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 @@ -43,25 +39,26 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _connection = MutableStateFlow(SeerrState()) - val connection: StateFlow = _connection + private val _connection = + MutableStateFlow(SeerrConnectionStatus.NotConfigured) + val connection: StateFlow = _connection val current: Flow = - _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current } + _connection.map { (it as? SeerrConnectionStatus.Success)?.current } val currentServer: Flow = - connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server } + connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } val currentUser: Flow = - connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user } + connection.map { (it 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.connection is SeerrConnectionStatus.Success && seerrApi.active } + connection.map { it is SeerrConnectionStatus.Success && seerrApi.active } fun clear() { - _connection.update { SeerrState() } + _connection.update { SeerrConnectionStatus.NotConfigured } seerrApi.update("", null) } @@ -70,7 +67,7 @@ class SeerrServerRepository user: SeerrUser, exception: Exception, ) { - _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) } + _connection.update { SeerrConnectionStatus.Error(server, user, exception) } seerrApi.update("", null) } @@ -81,9 +78,8 @@ class SeerrServerRepository ) { val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() _connection.update { - it.copy( - connection = - SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)), + SeerrConnectionStatus.Success( + CurrentSeerr(server, user, userConfig, publicSettings), ) } } @@ -173,7 +169,7 @@ class SeerrServerRepository suspend fun removeServerForCurrentUser(): Boolean { val user = - when (val conn = connection.value.connection) { + when (val conn = connection.first()) { SeerrConnectionStatus.NotConfigured -> return false is SeerrConnectionStatus.Error -> conn.user is SeerrConnectionStatus.Success -> conn.current.user @@ -182,81 +178,6 @@ 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 - } } /** @@ -264,11 +185,6 @@ class SeerrServerRepository */ typealias SeerrUserConfig = User -data class SeerrState( - val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured, - val pluginProvided: Boolean = false, -) - sealed interface SeerrConnectionStatus { data object NotConfigured : SeerrConnectionStatus @@ -289,15 +205,11 @@ 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 deleted file mode 100644 index 8b51ad63..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt +++ /dev/null @@ -1,96 +0,0 @@ -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 0b9da9f8..09450b34 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,27 +1,15 @@ package com.github.damontecres.wholphin.services -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao 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.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.PlaybackLanguageChoice 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 @@ -42,69 +30,50 @@ class StreamChoiceService @Inject constructor( private val serverRepository: ServerRepository, - private val seriesTrackChoiceDao: SeriesTrackChoiceDao, + private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, ) { private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration - suspend fun saveSeriesTrackChoice( + suspend fun updateAudio( dto: BaseItemDto, - type: MediaStreamType, - stream: RelativeMediaStream?, - activation: ActivationFlag = ActivationFlag.ACTIVATED, - ) { - 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, + audioLang: String, + ) = update(dto) { + it.copy( + audioLanguage = audioLang, ) - seriesTrackChoiceDao.save(newStc) } - suspend fun saveDisabledSeriesTrackChoice( + suspend fun updateSubtitles( dto: BaseItemDto, - type: MediaStreamType, - ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED) + subtitleLang: String?, + subtitlesDisabled: Boolean, + ) = update(dto) { + it.copy( + subtitleLanguage = if (subtitlesDisabled) null else subtitleLang, + subtitlesDisabled = subtitlesDisabled, + ) + } - suspend fun saveOnlyForcedSeriesTrackChoice( + suspend fun update( dto: BaseItemDto, - type: MediaStreamType, - ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED) + update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice, + ) { + 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) + } + } + + suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) = + dto.seriesId?.let { + playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it) + } /** * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution @@ -135,15 +104,21 @@ class StreamChoiceService */ suspend fun chooseAudioStream( source: MediaSourceInfo, - item: BaseItemDto, + seriesId: UUID?, itemPlayback: ItemPlayback?, - stc: List?, + plc: PlaybackLanguageChoice?, prefs: UserPreferences, - ): StreamChoiceResult? { - val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) + ): MediaStream? { + val plc = + plc ?: seriesId?.let { + playbackLanguageChoiceDao.get( + serverRepository.currentUser!!.rowId, + it, + ) + } return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.AUDIO } - chooseAudioStream(candidates, itemPlayback, stc, prefs) + chooseAudioStream(candidates, itemPlayback, plc, prefs) } } @@ -153,70 +128,59 @@ class StreamChoiceService fun chooseAudioStream( candidates: List, itemPlayback: ItemPlayback?, - stc: List, + playbackLanguageChoice: PlaybackLanguageChoice?, prefs: UserPreferences, - ): StreamChoiceResult = + ): MediaStream? = if (itemPlayback?.audioIndexEnabled == true) { - 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 { - // Otherwise, use the best scored stream - Timber.v("Using audio STC from %s", first.parentId) - result.first().stream with StreamChoiceReason.from(first, result) - } + candidates.firstOrNull { it.index == itemPlayback.audioIndex } } 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 + 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() + } else { + candidates.firstOrNull { it.isDefault } + ?: candidates.firstOrNull() + } } /** * Returns the subtitle stream that should play */ suspend fun chooseSubtitleStream( - item: BaseItemDto, source: MediaSourceInfo, audioStream: MediaStream?, + seriesId: UUID?, itemPlayback: ItemPlayback?, - stc: List?, + plc: PlaybackLanguageChoice?, prefs: UserPreferences, - ): StreamChoiceResult? = - source.mediaStreams?.letNotEmpty { streams -> + ): MediaStream? { + val plc = + plc ?: seriesId?.let { + playbackLanguageChoiceDao.get( + serverRepository.currentUser!!.rowId, + it, + ) + } + return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } - val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) chooseSubtitleStream( audioStream?.language, candidates, itemPlayback, - stc, + plc, prefs, ) } + } /** * Resolves ONLY_FORCED to an actual subtitle track index. @@ -225,7 +189,7 @@ class StreamChoiceService suspend fun resolveSubtitleIndex( source: MediaSourceInfo, audioStreamIndex: Int?, - item: BaseItemDto, + seriesId: UUID?, subtitleIndex: Int, prefs: UserPreferences, ): Int? = @@ -239,17 +203,17 @@ class StreamChoiceService val itemPlayback = ItemPlayback( userId = serverRepository.currentUser!!.rowId, - itemId = item.id, + itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution subtitleIndex = TrackIndex.ONLY_FORCED, ) chooseSubtitleStream( - item = item, source = source, audioStream = audioStream, + seriesId = seriesId, itemPlayback = itemPlayback, - stc = null, + plc = null, prefs = prefs, - )?.stream?.index + )?.index } /** @@ -259,81 +223,47 @@ class StreamChoiceService audioStreamLang: String?, candidates: List, itemPlayback: ItemPlayback?, - stc: List, + playbackLanguageChoice: PlaybackLanguageChoice?, prefs: UserPreferences, - ): 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, - ) - } - } + ): 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 } } else { - val subtitleLanguage = - getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) val subtitleMode = - 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 { + 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 + } + } } ?: SubtitlePlaybackMode.DEFAULT val candidates = candidates @@ -347,104 +277,73 @@ class StreamChoiceService }.thenByDescending { it.isForced && it.language.isUnknown } .thenByDescending { it.isForced }, ) - 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 { - candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() + 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) } - } - - 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 + } else { + candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() } } - return stream with StreamChoiceReason.UserPreferences + + 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 + } + } } } - /** - * 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) - } + /** 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) } /** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */ @@ -468,92 +367,19 @@ 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.isNullOrBlank() || + this == null || 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 * @@ -586,153 +412,3 @@ 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 e0dd472f..aa64058d 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,7 +5,6 @@ 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 @@ -64,8 +63,12 @@ class SuggestionsWorker } if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { - val result = serverRepository.restoreLastSession() - if (result !is RestoredSession.Success) { + var currentUser = serverRepository.current.value + if (currentUser == null) { + serverRepository.restoreSession(serverId, userId) + currentUser = serverRepository.current.value + } + if (currentUser == null) { 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 b208d663..06115982 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,7 +22,6 @@ 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 @@ -37,7 +36,6 @@ class UserSwitchListener private val seerrServerDao: SeerrServerDao, private val seerrApi: SeerrApi, private val homeSettingsService: HomeSettingsService, - private val serverPluginApi: ServerPluginApi, ) { init { context as AppCompatActivity @@ -67,78 +65,49 @@ class UserSwitchListener AppCompatDelegate.setApplicationLocales(localeList) } - // Check if plugin is installed, then for home settings & seerr settings + // Check for home settings launchIO { - 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 - + homeSettingsService.loadCurrentSettings(user.id) + } + if (BuildConfig.DISCOVER_ENABLED) { + // Check for seerr server launchIO { - // 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 + 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) } } } - 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 3a114017..06607cf9 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,8 +4,6 @@ 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 @@ -190,12 +188,6 @@ 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 78270a9b..29688664 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,7 +18,6 @@ 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 @@ -80,10 +79,6 @@ 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 a4d73caa..d3dbf4c7 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,7 +19,6 @@ 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 @@ -71,8 +70,12 @@ class TvProviderWorker if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { // Not active - val result = serverRepository.restoreLastSession() - if (result !is RestoredSession.Success) { + var currentUser = serverRepository.current.value + if (currentUser == null) { + serverRepository.restoreSession(serverId, userId) + currentUser = serverRepository.current.value + } + if (currentUser == null) { 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 a284ef0b..f774a0da 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,9 +8,6 @@ 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 @@ -208,13 +205,6 @@ 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() = @@ -310,55 +300,3 @@ 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 af67f12c..1d70553e 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,6 +44,7 @@ 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 @@ -52,18 +53,19 @@ import kotlin.time.Duration @OptIn(ExperimentalFoundationApi::class) @Composable -fun CollectionFolderViewScope.CollectionFolderGrid( +fun 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, ) { @@ -97,8 +99,18 @@ fun CollectionFolderViewScope.CollectionFolderGrid( modifier = Modifier.fillMaxSize(), initialPosition = initialPosition, positionCallback = positionCallback, - cardContent = { details -> - cardContent.invoke(this@CollectionFolderGrid, details) + 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, + ) }, columns = viewOptions.columns, spacing = viewOptions.spacing.dp, @@ -198,10 +210,10 @@ val CollectionType.baseItemKinds: List listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE) } - CollectionType.PHOTOS, - CollectionType.HOMEVIDEOS, - -> { - listOf(BaseItemKind.VIDEO, BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) + CollectionType.HOMEVIDEOS -> { + listOf(BaseItemKind.VIDEO) + listOf(BaseItemKind.PHOTO) + listOf(BaseItemKind.PHOTO_ALBUM) } CollectionType.MUSIC -> { @@ -224,6 +236,10 @@ 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 57ff8957..967ddb07 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,7 +28,6 @@ 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 @@ -60,7 +59,6 @@ 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 @@ -77,7 +75,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.extensions.ticks @Composable -fun CollectionFolderViewScope.CollectionFolderList( +fun CollectionFolderList( preferences: UserPreferences, collectionType: CollectionType?, focusedItem: BaseItem?, @@ -86,10 +84,10 @@ fun CollectionFolderViewScope.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, ) { @@ -155,8 +153,10 @@ fun CollectionFolderViewScope.CollectionFolderList( ).padding(8.dp), ) } - this@CollectionFolderList.CollectionFolderListContent( + CollectionFolderContent( items = items, + viewOptions = viewOptions, + collectionType = collectionType, listState = listState, position = position, positionFocusRequester = positionFocusRequester, @@ -167,7 +167,6 @@ fun CollectionFolderViewScope.CollectionFolderList( }, onClickItem = onClickItem, onLongClickItem = onLongClickItem, - itemContent = itemContent, modifier = Modifier .weight(1f) @@ -267,43 +266,44 @@ fun CollectionFolderListDetails( } @Composable -fun CollectionFolderViewScope.CollectionFolderListContent( +fun CollectionFolderContent( 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 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 onClick: () -> Unit = + remember(index, item) { + { item?.let { onClickItem.invoke(index, item) } } } - itemContent.invoke(this@CollectionFolderListContent, details) + 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)), + ) } } } @@ -522,12 +522,9 @@ fun ListItemAlbum( } val supportingContent = remember(item, dense) { - // 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) { + if (!dense && item?.subtitle != null) { @Composable { - Text(subtitle) + Text(item.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 8445dfa9..52918e57 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,25 +54,20 @@ 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 @@ -94,14 +89,12 @@ 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 @@ -153,9 +146,6 @@ 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) { @@ -178,25 +168,15 @@ 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 - ?: ancestorDisplayInfo?.viewOptions - ?: defaultViewOptions, + viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions, ) } val sortAndDirection = if (collectionFilter.useSavedLibraryDisplayInfo) { libraryDisplayInfo?.sortAndDirection - ?: ancestorDisplayInfo?.sortAndDirection } else { null } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT @@ -252,25 +232,6 @@ 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, @@ -296,17 +257,7 @@ 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) { @@ -318,19 +269,11 @@ class CollectionFolderViewModel override fun onClickRandom() { viewModelScope.launchIO { try { - 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) + val random = + (state.value.items.successValue as? BlockingList)?.randomBlocking() + Timber.d("Got random item: %s", random?.id) + random?.destination()?.let { + navigateTo(random.destination()) } } catch (ex: CancellationException) { throw ex @@ -456,27 +399,13 @@ class CollectionFolderViewModel } GetItemsFilterOverride.ARTIST -> { - when (artistSource) { - ArtistSource.ARTIST -> { - ApiRequestPager( - api, - createGetArtistsRequest(filter), - GetArtistsHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) - } - - ArtistSource.ALBUM_ARTIST -> { - ApiRequestPager( - api, - createGetAlbumArtistsRequest(filter), - GetAlbumArtistsHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) - } - } + ApiRequestPager( + api, + createGetArtistsRequest(filter), + GetArtistsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) } } @@ -495,17 +424,6 @@ 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, @@ -577,37 +495,18 @@ class CollectionFolderViewModel val filter = state.value.filter when (filter.override) { GetItemsFilterOverride.ARTIST -> { - 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, - ), - ) - } - } + GetArtistsHandler.countMatching( + api = api, + request = + createGetArtistsRequest(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 @@ -914,33 +813,6 @@ 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) @@ -1103,41 +975,9 @@ 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) { - scope.CollectionFolderGrid( - preferences = preferences, - initialPosition = savedPosition, - items = pager.data, - sortAndDirection = state.sortAndDirection, - modifier = Modifier.fillMaxSize(), - gridFocusRequester = gridFocusRequester, - onClickItem = gridActions.onClickItem, - onLongClickItem = gridActions.onLongClickItem!!, - positionCallback = { columns, pos -> - showHeader = pos < columns - position = pos - positionCallback?.invoke(columns, pos) - }, - letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, - onClickPlay = gridActions.onClickPlayRemoteButton!!, - focusedItem = focusedItem, - cardContent = gridCardContent, - ) - } else { - scope.CollectionFolderList( + CollectionFolderGrid( preferences = preferences, collectionType = item?.data?.collectionType, initialPosition = savedPosition, @@ -1153,9 +993,30 @@ fun CollectionFolderViewContent( positionCallback?.invoke(columns, pos) }, letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, + viewOptions = state.viewOptions, + onClickPlay = gridActions.onClickPlayRemoteButton!!, + focusedItem = focusedItem, + ) + } else { + CollectionFolderList( + preferences = preferences, + collectionType = item?.data?.collectionType, + initialPosition = savedPosition, + items = pager.data, + sortAndDirection = state.sortAndDirection, + modifier = Modifier.fillMaxSize(), + gridFocusRequester = gridFocusRequester, + onClickItem = gridActions.onClickItem, + onLongClickItem = gridActions.onLongClickItem!!, + positionCallback = { columns, pos -> + showHeader = pos < columns + position = pos + positionCallback?.invoke(columns, pos) + }, + letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, + viewOptions = state.viewOptions, onClickPlay = gridActions.onClickPlayRemoteButton!!, focusedItem = focusedItem, - itemContent = listItemContent, ) } androidx.compose.animation.AnimatedVisibility( @@ -1186,7 +1047,6 @@ fun CollectionFolderViewContent( ViewOptionsDialog( viewOptions = state.viewOptions, defaultViewOptions = defaultViewOptions, - showArtistSource = state.filter.override == GetItemsFilterOverride.ARTIST, onDismissRequest = { showViewOptions = false viewActions.saveViewOptions(state.viewOptions) @@ -1195,13 +1055,3 @@ 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 f87010d9..10a41af3 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,12 +209,7 @@ fun ContextMenu( favorite = item.favorite, seriesId = item.data.seriesId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), - canClearChosenStreams = - chosenStreams.let { - it?.itemPlayback != null || - it?.audioStc?.isNotEmpty() == true || - it?.subtitleStc?.isNotEmpty() == true - }, + canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, contextMenu = contextMenu, actions = actions, onChooseVersion = { @@ -240,11 +235,11 @@ fun ContextMenu( resources = resources, streams = source.mediaStreams.orEmpty(), type = type, - streamChoice = + currentIndex = if (type == MediaStreamType.AUDIO) { - chosenStreams?.audioStream + chosenStreams?.audioStream?.index } else { - chosenStreams?.subtitleStream + chosenStreams?.subtitleStream?.index }, 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 76b7dc77..d7bd9951 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,7 +64,6 @@ 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 @@ -231,35 +230,28 @@ 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 - }.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.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 + }, ) } } @@ -273,10 +265,20 @@ 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, + 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)), ) { Text( text = title, @@ -450,14 +452,12 @@ 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, cancelStringRes, confirmStringRes) + ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor) }, ) @@ -472,8 +472,6 @@ 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), @@ -504,11 +502,11 @@ fun ConfirmDialogContent( modifier = Modifier.fillMaxWidth(), ) { TextButton( - stringRes = cancelStringRes, + stringRes = R.string.cancel, onClick = onCancel, ) TextButton( - stringRes = confirmStringRes, + stringRes = R.string.confirm, onClick = onConfirm, ) } @@ -653,13 +651,12 @@ fun resourceFor(type: MediaStreamType): Int = fun chooseStream( resources: Resources, streams: List, - streamChoice: StreamChoiceResult?, + currentIndex: Int?, 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))), @@ -677,16 +674,6 @@ fun chooseStream( }, supportingContent = { }, - trailingContent = - if (currentIndex == null && streamChoice != null) { - { - Text( - text = streamChoice.reason.description.getString(), - ) - } - } else { - null - }, onClick = { onClick.invoke(TrackIndex.DISABLED) }, ), ) @@ -732,16 +719,6 @@ 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 73e9af44..9ac5adc0 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_rated, + title = R.string.top_unwatched, handler = GetItemsRequestHandler, request = GetItemsRequest( @@ -69,6 +69,7 @@ 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 b69e1126..9ff0e2c0 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,7 +97,6 @@ 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 4268f2d9..c5da7565 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,11 +95,7 @@ fun rememberLogoUrl(item: BaseItem?): String? { item.data.seriesId != null && item.data.parentLogoImageTag != null ) { - imageUrlService.getItemImageUrl( - itemId = item.data.seriesId!!, - imageType = ImageType.LOGO, - tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId }, - ) + imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO) } 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 94842ef5..083dded6 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?.stream, - chosenStreams?.subtitleStream?.stream, + chosenStreams?.audioStream, + chosenStreams?.subtitleStream, 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 df923a55..a1237acd 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,7 +55,6 @@ fun ViewOptionsDialog( onDismissRequest: () -> Unit, onViewOptionsChange: (ViewOptions) -> Unit, defaultViewOptions: ViewOptions = ViewOptions(), - showArtistSource: Boolean = false, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } @@ -73,11 +72,11 @@ fun ViewOptionsDialog( window.setDimAmount(0f) } val options = - remember(viewOptions.type, showArtistSource) { + remember(viewOptions.type) { when (viewOptions.type) { - ViewOptionsType.GRID -> ViewOptions.buildGridOptions(showArtistSource) - ViewOptionsType.LIST -> ViewOptions.buildListOptions(showArtistSource) - ViewOptionsType.DENSE_LIST -> ViewOptions.buildListOptions(showArtistSource) + ViewOptionsType.GRID -> ViewOptions.GRID_OPTIONS + ViewOptionsType.LIST -> ViewOptions.LIST_OPTIONS + ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS } } Column( @@ -131,12 +130,6 @@ 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 * @@ -153,7 +146,6 @@ 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 = @@ -258,34 +250,12 @@ 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, ) - /** - * Get the options for a grid [ViewOptions] - */ - fun buildGridOptions(showArtistSource: Boolean) = - buildList { - if (showArtistSource) add(ViewOptionsArtistSource) - addAll(GRID_OPTIONS) - } - - private val GRID_OPTIONS = + val GRID_OPTIONS = listOf( ViewOptionsTypePref, ViewOptionsImageType, @@ -299,16 +269,7 @@ data class ViewOptions( ViewOptionsReset, ) - /** - * Get the options for a list [ViewOptions] - */ - fun buildListOptions(showArtistSource: Boolean) = - buildList { - if (showArtistSource) add(ViewOptionsArtistSource) - addAll(LIST_OPTIONS) - } - - private val LIST_OPTIONS = + val LIST_OPTIONS = listOf( ViewOptionsTypePref, ViewOptionsDetailHeader, @@ -377,22 +338,3 @@ 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 fb4cdbdd..7c8268b0 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,16 +9,11 @@ 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 @@ -37,7 +32,6 @@ 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 @@ -46,11 +40,9 @@ 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 @@ -64,11 +56,9 @@ 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 @@ -78,18 +68,17 @@ 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 display: Display by lazy { + val supportedModes by lazy { val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - displayManager.getDisplay(Display.DEFAULT_DISPLAY) + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + display.supportedModes.orEmpty() } val av1Included by lazy { @@ -170,7 +159,7 @@ class DebugViewModel state.update { it.copy( deviceInfo = deviceInfoList, - displayModes = display.supportedModes.map { it.toDisplayMode(display) }, + displayModes = supportedModes.map { it.toString() }, ) } } @@ -180,13 +169,6 @@ 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 @@ -224,20 +206,6 @@ 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 { @@ -294,9 +262,8 @@ 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 @@ -416,7 +383,6 @@ val AudioDeviceInfo.details: String "productName=$productName, address=$addressStr)" } -@OptIn(ExperimentalGridApi::class) @Composable fun DebugPage( preferences: UserPreferences, @@ -500,53 +466,14 @@ fun DebugPage( BodyText(it) } - SubSectionTitle("Audio Devices") - state.audioInfo.forEach { + SubSectionTitle("Display Modes") + state.displayModes.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()) - } + SubSectionTitle("Audio Devices") + state.audioInfo.forEach { + BodyText(it) } } } @@ -638,29 +565,11 @@ private fun SubSectionTitle(text: String) { } @Composable -private fun BodyText( - text: String, - modifier: Modifier = Modifier, -) { +private fun BodyText(text: String) { 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, ) } @@ -692,45 +601,3 @@ 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 5690fadc..adbd9a44 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,7 +43,6 @@ 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 @@ -701,25 +700,11 @@ class FavoritesViewModel viewModelScope.launchIO { try { collectionStateFor(type)?.let { collectionState -> - 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, - ) + val random = + (collectionState.items.successValue as? BlockingList)?.randomBlocking() + Timber.d("Got random item: %s", random?.id) + random?.destination()?.let { + navigateTo(random.destination()) } } } 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 4e49bd39..1fd7ea9f 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.HomeDataService +import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavDrawerService @@ -34,6 +34,7 @@ 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 @@ -74,7 +75,7 @@ class HomeRowGridViewModel val serverRepository: ServerRepository, private val userPreferencesService: UserPreferencesService, private val navDrawerService: NavDrawerService, - private val homeDataService: HomeDataService, + private val homeSettingsService: HomeSettingsService, private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, private val navigationManager: NavigationManager, @@ -127,7 +128,7 @@ class HomeRowGridViewModel val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val result = - homeDataService.fetchDataForRow( + homeSettingsService.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 64c48f0e..27212b5e 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,7 +257,6 @@ 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 33960f83..b7085db6 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,26 +1,49 @@ 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 @@ -28,49 +51,60 @@ 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.LocalTextStyle +import androidx.tv.material3.ListItem 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.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo 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.cards.FavoriteIndicator -import com.github.damontecres.wholphin.ui.cards.WatchedIcon -import com.github.damontecres.wholphin.ui.collectLatestIn +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.ItemCardImage 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.GridClickActions -import com.github.damontecres.wholphin.ui.components.ListItemWrapper +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.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.PlaylistSortOptions +import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions 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.gt +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.settings.MoveDirection @@ -79,9 +113,13 @@ 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.successValue +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 dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -89,62 +127,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, - collectionState: StateFlow, - ): PlaylistViewModel + fun create(itemId: UUID): PlaylistViewModel } - private val _state = MutableStateFlow(PlaylistDetailsState()) - val state: StateFlow = _state - + val state = MutableStateFlow(PlaylistDetailsState()) 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 -> @@ -177,12 +215,95 @@ class PlaylistViewModel false } } ?: false + state.update { + it.copy( + playlist = playlist, + canEdit = canEdit, + ) + } - _state.update { it.copy(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() determineMediaType() } catch (ex: Exception) { - Timber.e(ex, "Error fetching playlist info for %s", itemId) -// _state.update { it.copy(item = DataLoadingState.Error(ex)) } + 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), + ) + } } } } @@ -192,49 +313,77 @@ class PlaylistViewModel * * In theory, the server will set the type, but sometimes it doesn't */ - 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 + 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 - 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 - } - } - if (types.keys.size == 1) { - types.keys.first() - } else { - MediaType.UNKNOWN + else -> MediaType.UNKNOWN } - } else { - MediaType.UNKNOWN } + if (types.keys.size == 1) { + types.keys.first() } else { - mediaType + MediaType.UNKNOWN } - Timber.d("mediaType=%s", mediaType) - _state.update { - it.copy(mediaType = mediaType) + } else { + MediaType.UNKNOWN } + } 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( @@ -243,12 +392,11 @@ class PlaylistViewModel ) { viewModelScope.launchIO { try { - api.playlistsApi.removeItemFromPlaylist( - this@PlaylistViewModel.itemId.toServerString(), - listOf(itemId.toServerString()), + playlistCreator.removeFromServerPlaylist( + playlistId = this@PlaylistViewModel.itemId, + itemId = itemId, ) - (collectionState.value.items.successValue as? ApiRequestPager<*>) - ?.refreshPagesAfter(index) + (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index) } catch (ex: CancellationException) { throw ex } catch (ex: Exception) { @@ -275,15 +423,31 @@ class PlaylistViewModel itemId = itemId.toServerString(), newIndex = newIndex, ) - (collectionState.value.items.successValue as? ApiRequestPager<*>)?.refreshPagesAfter( - index - 1, - ) + (state.value.items 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, ) @@ -292,33 +456,14 @@ 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, collectionViewModel.state) }, + creationCallback = { it.create(destination.itemId) }, ), addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val state by collectionViewModel.state.collectAsState() - val playlistState by viewModel.state.collectAsState() + val state 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) } @@ -331,15 +476,15 @@ fun PlaylistDetails( shuffle: Boolean, mediaTypeOverride: MediaType? = null, ) { - when (mediaTypeOverride ?: playlistState.mediaType) { + when (mediaTypeOverride ?: state.mediaType) { MediaType.VIDEO -> { - collectionViewModel.navigateTo( + viewModel.navigationManager.navigateTo( Destination.PlaybackList( itemId = destination.itemId, startIndex = index, shuffle = shuffle, - filter = state.filter, - sortAndDirection = state.sortAndDirection, + filter = state.filterAndSort.filter, + sortAndDirection = state.filterAndSort.sortAndDirection, ), ) } @@ -353,152 +498,93 @@ fun PlaylistDetails( } } } - - 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, - ) - } - } - - val actions = + val musicContextActions = 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) } + 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) }, - onClickPlayRemoteButton = { index, item -> play(index, item, false) }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = viewModel::deleteItem, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, ) } - 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) - } + 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) }, - 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 }, + 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) + } + }, + 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, + modifier = modifier, ) showContextMenu?.let { contextMenu -> ContextMenuDialog( @@ -535,7 +621,294 @@ fun PlaylistDetails( } @Composable -fun CollectionFolderViewScope.PlaylistItem( +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( item: BaseItem?, index: Int, onClick: () -> Unit, @@ -550,18 +923,19 @@ fun CollectionFolderViewScope.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), ) { - ListItemWrapper( - dense = dense, + ListItem( selected = false, onClick = onClick, onLongClick = onLongClick, @@ -580,21 +954,19 @@ fun CollectionFolderViewScope.PlaylistItem( }, trailingContent = { val duration = - remember(item?.data?.runTimeTicks) { - when (item?.type) { - BaseItemKind.AUDIO -> { - item.data.runTimeTicks - ?.ticks - ?.roundSeconds - } + 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 -> @@ -608,25 +980,15 @@ fun CollectionFolderViewScope.PlaylistItem( val resources = LocalResources.current val durationText = remember(resources, duration) { resources.formatDuration(duration) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column { + Column { + Text( + text = durationText, + ) + if (item?.type != BaseItemKind.AUDIO) { Text( - text = durationText, + text = stringResource(R.string.ends_at, endTimeStr), + style = MaterialTheme.typography.bodySmall, ) - 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) } } } @@ -634,29 +996,41 @@ fun CollectionFolderViewScope.PlaylistItem( leadingContent = { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - 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, - ) - } + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.labelLarge, + ) 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), @@ -746,10 +1120,3 @@ 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 d0017d84..c5686e8f 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,8 +42,9 @@ 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.canRequestMovies +import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService @@ -83,12 +84,14 @@ 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) @@ -150,7 +153,6 @@ fun DiscoverMovieDetails( RequestMovieDialog( loading = state.profileLoading, data = state.requestData, - requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, movie = movie, onSubmit = { @@ -251,7 +253,7 @@ fun DiscoverMovieDetailsContent( } } }, - canRequest = userConfig.canRequestMovies, + canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), 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 407c6935..658e4ffe 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,8 +75,6 @@ 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 } @@ -196,7 +194,7 @@ class DiscoverMovieViewModel return@launchIO } try { - val postRequest = + seerrService.api.requestApi.requestPost( RequestPostRequest( is4k = request.is4k, mediaId = request.movieId, @@ -210,9 +208,8 @@ 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 860af84e..ef96bb7e 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,8 +37,9 @@ 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.canRequestTv +import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService @@ -87,7 +88,6 @@ 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,7 +112,6 @@ fun DiscoverSeriesDetails( DiscoverSeriesDetailsContent( preferences = preferences, series = item, - availability = state.availability, userConfig = userConfig, rating = state.rating, canCancel = state.canCancelRequest, @@ -189,7 +188,6 @@ fun DiscoverSeriesDetails( title = state.tvSeries.successValue?.name ?: "", seasons = state.seasons, seasons4k = state.seasons4k, - requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, onSubmit = { showRequestSeasonDialog = false @@ -214,7 +212,6 @@ fun DiscoverSeriesDetailsContent( preferences: UserPreferences, userConfig: SeerrUserConfig?, series: TvDetails, - availability: SeerrAvailability, rating: SourceRatings?, canCancel: Boolean, seasons: List, @@ -278,7 +275,9 @@ fun DiscoverSeriesDetailsContent( .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandableDiscoverButtons( - availability = availability, + availability = + SeerrAvailability.from(series.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, requestOnClick = requestOnClick, pendingOnClick = requestOnClick, cancelOnClick = cancelOnClick, @@ -292,7 +291,7 @@ fun DiscoverSeriesDetailsContent( } } }, - canRequest = userConfig.canRequestTv, + canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), 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 c20ab9c9..b4d6f37f 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,10 +75,6 @@ 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 } @@ -112,7 +108,7 @@ class DiscoverSeriesViewModel backdropService.submit(discoveredItem) updateSeasonStatus(tv) - updateAvailability() + updateCanCancel() viewModelScope.launchIO { val rating = @@ -194,7 +190,6 @@ 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() @@ -235,10 +230,7 @@ class DiscoverSeriesViewModel seasonStatus[season.seasonNumber] = new } editable[season.seasonNumber] = - currentUserId == req.requestedBy?.id && ( - req.status == RequestStatus.PENDING.status || - req.status == RequestStatus.DECLINED.status - ) + currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status } } } @@ -307,7 +299,7 @@ class DiscoverSeriesViewModel } } - private suspend fun updateAvailability() { + private suspend fun updateCanCancel() { val user = userConfig.firstOrNull() val canCancel = canUserCancelRequest( @@ -316,32 +308,7 @@ class DiscoverSeriesViewModel ?.mediaInfo ?.requests, ) - 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, - ) - } + _state.update { it.copy(canCancelRequest = canCancel) } } fun requestOnClick() { @@ -424,7 +391,7 @@ class DiscoverSeriesViewModel fetchAndSetItem().await()?.let { updateSeasonStatus(it) - updateAvailability() + updateCanCancel() } } } @@ -445,7 +412,7 @@ class DiscoverSeriesViewModel fetchAndSetItem().await()?.let { updateSeasonStatus(it) - updateAvailability() + updateCanCancel() } } } @@ -464,5 +431,4 @@ 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 ef6e54b7..7a009f09 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,7 +43,6 @@ import com.github.damontecres.wholphin.util.LoadingState fun RequestMovieDialog( loading: LoadingState, data: SeerrRequestData, - requestEnabled: Boolean, request4kEnabled: Boolean, movie: MovieDetails, onSubmit: (MovieRequest) -> Unit, @@ -55,11 +54,10 @@ fun RequestMovieDialog( RequestMovie( loading = loading, data = data, - requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, movie = movie, onSubmit = onSubmit, - modifier = Modifier.padding(24.dp), + modifier = Modifier.padding(16.dp), ) } } @@ -68,7 +66,6 @@ fun RequestMovieDialog( fun RequestMovie( loading: LoadingState, data: SeerrRequestData, - requestEnabled: Boolean, request4kEnabled: Boolean, movie: MovieDetails, onSubmit: (MovieRequest) -> Unit, @@ -93,8 +90,7 @@ fun RequestMovie( if (data.profiles.isEmpty() && data.rootFolders.isEmpty() && data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty() ) { - // Both enabled, show choice - if (requestEnabled && request4kEnabled) { + if (request4kEnabled) { val items = remember { listOf( @@ -122,13 +118,12 @@ fun RequestMovie( ) } else { LaunchedEffect(Unit) { - onSubmit.invoke(MovieRequest(data, movie.id, request4kEnabled, null, null)) + onSubmit.invoke(MovieRequest(data, movie.id, false, null, null)) } } } else { RequestMovieWithOptions( data = data, - requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, movie = movie, onSubmit = onSubmit, @@ -142,13 +137,12 @@ 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(!requestEnabled) } + var is4k by remember { mutableStateOf(request4kEnabled) } var profile by remember(is4k) { mutableStateOf( if (is4k) { @@ -191,7 +185,7 @@ private fun RequestMovieWithOptions( .fillMaxWidth() .padding(bottom = 8.dp), ) { - if (requestEnabled && request4kEnabled) { + if (request4kEnabled) { ClickSwitch( label = stringResource(R.string.request_4k), checked = is4k, @@ -383,7 +377,7 @@ fun MovieRequestPreview() { ), profiles4k = listOf( - SeerrProfile(1, "4K", true), + SeerrProfile(1, "HD", true), SeerrProfile(2, "Ultra HD", false), ), rootFolders4k = @@ -391,8 +385,7 @@ fun MovieRequestPreview() { SeerrRootFolder(1, "/movies", "400GB", true), ), ), - requestEnabled = true, - request4kEnabled = true, + request4kEnabled = false, 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 91a10279..bc11825f 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,12 +65,11 @@ fun RequestSeasons( seasons: List, seasons4k: List, data: SeerrRequestData, - requestEnabled: Boolean, request4kEnabled: Boolean, onSubmit: (TvRequest) -> Unit, modifier: Modifier = Modifier, ) { - var is4k by remember { mutableStateOf(!requestEnabled) } + var is4k by remember { mutableStateOf(request4kEnabled) } val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons } val allSeasonNumbers = @@ -162,7 +161,7 @@ fun RequestSeasons( .fillMaxWidth() .padding(vertical = 8.dp), ) { - if (requestEnabled && request4kEnabled) { + if (request4kEnabled) { ClickSwitch( label = stringResource(R.string.request_4k), checked = is4k, @@ -392,7 +391,6 @@ fun RequestSeasonsDialog( data: SeerrRequestData, seasons: List, seasons4k: List, - requestEnabled: Boolean, request4kEnabled: Boolean, onSubmit: (TvRequest) -> Unit, onDismissRequest: () -> Unit, @@ -420,7 +418,6 @@ fun RequestSeasonsDialog( data = data, seasons = seasons, seasons4k = seasons4k, - requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, onSubmit = onSubmit, modifier = @@ -478,7 +475,6 @@ 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 97aaaf86..a45fbda0 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,10 +145,11 @@ 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, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -162,6 +163,7 @@ class EpisodeViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -169,7 +171,10 @@ class EpisodeViewModel trackIndex = trackIndex, type = type, ) - val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, 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 bcba145c..470f33c2 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,11 +149,7 @@ class LiveTvViewModel number = it.channelNumber, name = it.channelName ?: it.name, imageUrl = - imageUrlService.getItemImageUrl( - itemId = it.id, - imageType = ImageType.PRIMARY, - tag = it.imageTags?.get(ImageType.PRIMARY), - ), + imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY), favorite = it.userData?.isFavorite == true, ) } @@ -303,7 +299,6 @@ 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 9a8dd033..db6a6f6a 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,10 +223,11 @@ 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, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -240,6 +241,7 @@ class MovieViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -247,7 +249,10 @@ class MovieViewModel trackIndex = trackIndex, type = type, ) - val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, 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 3bbc3a6b..32f1d484 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,10 +561,11 @@ 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, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -578,6 +579,7 @@ class SeriesViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -585,7 +587,10 @@ class SeriesViewModel trackIndex = trackIndex, type = type, ) - val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, 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 d02ba907..847e2e69 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,7 +11,6 @@ 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 @@ -63,7 +62,6 @@ 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, @@ -188,7 +186,7 @@ class HomeViewModel semaphore.withPermit { Timber.v("Fetching row: %s", row) try { - homeDataService.fetchDataForRow( + homeSettingsService.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 ae958a93..ad433c23 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,9 +33,6 @@ 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 74c546b5..894980c4 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,6 +31,10 @@ fun HomeSettingsGlobal( preferences: AppPreferences, onPreferenceChange: (AppPreferences) -> Unit, onClickResize: (Int) -> Unit, + onClickSave: () -> Unit, + onClickLoad: () -> Unit, + onClickLoadWeb: () -> Unit, + onClickReset: () -> Unit, onClickViewNextUp: () -> Unit, modifier: Modifier = Modifier, ) { @@ -142,6 +146,62 @@ 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 0bd6b7db..5a2d6c78 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,6 +1,5 @@ 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 @@ -65,14 +64,11 @@ 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( @@ -90,10 +86,6 @@ fun HomeSettingsPage( } } - BackHandler(settingsHaveChanged) { - showSaveDialog = true - } - Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, @@ -145,7 +137,6 @@ fun HomeSettingsPage( position = RowColumn(index, 0) } }, - onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) }, modifier = destModifier, ) } @@ -291,16 +282,6 @@ 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) { @@ -313,13 +294,6 @@ fun HomeSettingsPage( viewModel.loadFromRemote() } }, - serverPluginActive = serverPluginActive, - onClickLoadPlugin = { - showConfirmDialog = - ShowConfirm(R.string.overwrite_local_settings) { - viewModel.loadFromRemotePlugin() - } - }, onClickLoadWeb = { showConfirmDialog = ShowConfirm(R.string.overwrite_local_settings) { @@ -332,6 +306,9 @@ fun HomeSettingsPage( addRow(false) { viewModel.resetToDefault() } } }, + onClickViewNextUp = { + showRemovedNextUpDialog = true + }, modifier = destModifier, ) } @@ -398,29 +375,6 @@ 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 72289463..dc527ed4 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,11 +49,6 @@ 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, @@ -63,14 +58,13 @@ 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 = POS_PRESETS + 1 // +1 for divider + val itemsBeforeRows = 4 val focusRequesters = remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } } @@ -86,7 +80,7 @@ fun HomeSettingsRowList( modifier = modifier .fillMaxHeight() - .focusRestorer(focusRequesters[POS_ADD_ROW]), + .focusRestorer(focusRequesters[0]), ) { item { HomeSettingsListItem( @@ -99,30 +93,10 @@ fun HomeSettingsRowList( ) }, onClick = { - position = POS_ADD_ROW + position = 0 onClickAdd.invoke() }, - 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]), + modifier = Modifier.focusRequester(focusRequesters[0]), ) } item { @@ -136,10 +110,10 @@ fun HomeSettingsRowList( ) }, onClick = { - position = POS_SETTINGS + position = 1 onClickSettings.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]), + modifier = Modifier.focusRequester(focusRequesters[1]), ) } item { @@ -158,10 +132,10 @@ fun HomeSettingsRowList( ) }, onClick = { - position = POS_PRESETS + position = 2 onClickPresets.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]), + modifier = Modifier.focusRequester(focusRequesters[2]), ) } 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 deleted file mode 100644 index 8139b651..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt +++ /dev/null @@ -1,111 +0,0 @@ -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 1082a23f..e3eafcbb 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,18 +24,16 @@ 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 @@ -50,16 +48,13 @@ 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 @@ -81,55 +76,37 @@ 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, - val navigationManager: NavigationManager, + @param:IoCoroutineScope private val ioScope: CoroutineScope, ) : 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 originalSettings: HomePageSettings? = null + private var originalLocalSettings: HomePageSettings? = null + private var originalRemoteSettings: 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 } - originalSettings = currentSettings.asHomePageSettings() + originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id) + originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id) 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, ) @@ -157,7 +134,7 @@ class HomeSettingsViewModel viewModelScope.async(WholphinDispatchers.IO) { semaphore.withPermit { try { - homeDataService.fetchDataForRow( + homeSettingsService.fetchDataForRow( row = row.config, scope = viewModelScope, prefs = prefs, @@ -519,7 +496,7 @@ class HomeSettingsViewModel } } - fun saveToRemote(): Job = + fun saveToRemote() { viewModelScope.launchIO { serverRepository.currentUser?.let { user -> Timber.d("Saving home settings to remote") @@ -529,10 +506,6 @@ class HomeSettingsViewModel try { Timber.d("saveToRemote") homeSettingsService.saveToServer(user.id, settings) - homeSettingsService.updateCurrent( - HomePageSettingsSource.SERVER_PROFILE, - settings, - ) showSaveToast() } catch (ex: Exception) { Timber.e(ex) @@ -540,6 +513,7 @@ class HomeSettingsViewModel } } } + } fun loadFromRemote() { viewModelScope.launchIO { @@ -556,10 +530,7 @@ class HomeSettingsViewModel } idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy( - source = HomePageSettingsSource.SERVER_PROFILE, - rows = newRows, - ) + it.copy(rows = newRows) } } else { Timber.v("No remote settings") @@ -589,10 +560,7 @@ class HomeSettingsViewModel Timber.v("Got web settings") idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy( - source = HomePageSettingsSource.LOCAL, - rows = result.rows, - ) + it.copy(rows = result.rows) } } else { Timber.v("No web settings") @@ -607,19 +575,34 @@ class HomeSettingsViewModel } } - fun saveToLocal(): Job = - viewModelScope.launchIO { + fun saveToLocal() { + // This uses injected ioScope so that it will still run when the page is closing + ioScope.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) @@ -627,11 +610,17 @@ 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) { @@ -660,32 +649,12 @@ class HomeSettingsViewModel fun resetToDefault() = viewModelScope.launchIO { - val user = serverRepository.currentUser ?: return@launchIO + val userId = serverRepository.currentUser?.id ?: return@launchIO _state.update { it.copy(loading = LoadingState.Loading) } - 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) - + val result = homeSettingsService.createDefault(userId) idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy( - source = result.source, - rows = result.rows, - rowData = emptyList(), - ) + it.copy(rows = result.rows, rowData = emptyList()) } fetchRowData() } @@ -777,10 +746,6 @@ class HomeSettingsViewModel is HomeRowConfig.TvChannels -> { it.config.updateViewOptions(preset.liveTv) } - - is HomeRowConfig.CustomEndpoint -> { - it.config - } } it.copy(config = newConfig) } @@ -802,43 +767,6 @@ 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, @@ -933,13 +861,10 @@ 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, @@ -948,7 +873,6 @@ 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 deleted file mode 100644 index c7531b6d..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt +++ /dev/null @@ -1,54 +0,0 @@ -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 110cb637..a65c97e8 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 c26cb891..17906fe2 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,7 +80,6 @@ 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 @@ -574,7 +573,6 @@ fun NavigationDrawerScope.ProfileIcon( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - val focused by interactionSource.collectIsFocusedAsState() NavigationDrawerItem( modifier = modifier, selected = false, @@ -597,7 +595,7 @@ fun NavigationDrawerScope.ProfileIcon( interactionSource = interactionSource, ) { Text( - modifier = Modifier.enableMarquee(focused), + modifier = Modifier, text = user.name ?: user.id.toString(), maxLines = 1, ) @@ -641,7 +639,7 @@ fun NavigationDrawerScope.IconNavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier.enableMarquee(focused), + modifier = Modifier, text = text, maxLines = 1, ) @@ -735,7 +733,7 @@ fun NavigationDrawerScope.NavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier.enableMarquee(focused), + modifier = Modifier, 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 b17036b8..871264c2 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,7 +25,6 @@ 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 @@ -159,11 +158,7 @@ class PlayExternalViewModel is PlaylistItem.Media -> playlistItem.item } val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig) - val subtitleStc = - streamChoiceService.getSeriesTrackChoices( - item.data, - SeriesTrackChoiceType.SUBTITLE, - ) + val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) if (mediaSource == null) { Timber.w("Media source is null") return@launchDefault @@ -173,14 +168,13 @@ class PlayExternalViewModel val subtitleIndex = streamChoiceService .chooseSubtitleStream( - item = item.data, source = mediaSource, audioStream = null, + seriesId = item.data.seriesId, itemPlayback = playbackConfig, - stc = subtitleStc, + plc = plc, prefs = prefs, - )?.stream - ?.index + )?.index val externalSubtitles = mediaSource.mediaStreams ?.filter { it.isExternal } @@ -243,8 +237,6 @@ 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 3165246f..491498cb 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,10 +42,8 @@ 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 @@ -62,7 +60,6 @@ enum class PlaybackDialogType { PLAYBACK_SPEED, VIDEO_SCALE, SUBTITLE_DELAY, - BITRATE, } data class PlaybackSettings( @@ -76,7 +73,6 @@ data class PlaybackSettings( val subtitleDelay: Duration, val hasSubtitleDownloadPermission: Boolean, val playbackSpeedEnabled: Boolean, - val maxBitrate: Long, ) /** @@ -166,13 +162,6 @@ 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, @@ -282,29 +271,6 @@ 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 86d64651..483083f2 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,10 +322,6 @@ fun PlaybackPageContent( player.seekToPrevious() } } - - is PlaybackAction.ChangeBitrate -> { - viewModel.changeMaxBitrate(it.maxBitrate) - } } } @@ -756,7 +752,6 @@ 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 cca3a0d0..b42b7151 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,7 +24,6 @@ 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 24bd8ee9..012038a8 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,7 +5,6 @@ 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 @@ -35,7 +34,6 @@ 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 @@ -200,7 +198,7 @@ class PlaybackViewModel val currentUserDto = serverRepository.currentUserDtoFlow - @VisibleForTesting + // Exposed for testing val initJob: Job init { @@ -292,16 +290,8 @@ 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() } } @@ -499,16 +489,7 @@ class PlaybackViewModel null } - val audioStc = - streamChoiceService.getSeriesTrackChoices( - base, - SeriesTrackChoiceType.AUDIO, - ) - val subtitleStc = - streamChoiceService.getSeriesTrackChoices( - base, - SeriesTrackChoiceType.AUDIO, - ) + val plc = streamChoiceService.getPlaybackLanguageChoice(base) if (mediaSource == null && !isLiveTv) { showToast( @@ -537,31 +518,30 @@ class PlaybackViewModel val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty() val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty() - val audioResult = + val audioStream = mediaSource?.let { streamChoiceService .chooseAudioStream( source = mediaSource, - item = base, + seriesId = base.seriesId, itemPlayback = itemPlayback, - stc = audioStc, + plc = plc, prefs = preferences, ) } - val audioIndex = audioResult?.stream?.index + val audioIndex = audioStream?.index val subtitleIndex = mediaSource?.let { streamChoiceService .chooseSubtitleStream( - item = base, source = mediaSource, - audioStream = audioResult?.stream, + audioStream = audioStream, + seriesId = base.seriesId, itemPlayback = itemPlayback, - stc = subtitleStc, + plc = plc, prefs = preferences, - )?.stream - ?.index + )?.index } Timber.d( "Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s", @@ -636,7 +616,6 @@ 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 @@ -647,8 +626,7 @@ class PlaybackViewModel state.value.currentPlayback?.let { currentPlayback -> if (currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && - enableDirectPlay && - state.value.maxBitrate == maxBitrate + enableDirectPlay ) { val wasSuccessful = changeStreamsDirectPlay( @@ -661,17 +639,18 @@ class PlaybackViewModel } Timber.i( - "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s", + "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%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( @@ -717,7 +696,6 @@ class PlaybackViewModel api.videosApi.getVideoStreamUrl( itemId = itemId, mediaSourceId = source.id, - liveStreamId = source.liveStreamId, static = true, tag = source.eTag, playSessionId = response.playSessionId, @@ -977,7 +955,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = currentPlayback.mediaSourceInfo, audioStreamIndex = index, - item = currentItem.item.data, + seriesId = currentItem.item.data.seriesId, subtitleIndex = currentPlayback.subtitleIndex, prefs = preferences, ) @@ -1013,7 +991,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = currentPlayback.mediaSourceInfo, audioStreamIndex = currentPlayback.audioIndex, - item = currentItem.item.data, + seriesId = currentItem.item.data.seriesId, subtitleIndex = index, prefs = preferences, ) @@ -1031,26 +1009,6 @@ 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, @@ -1152,7 +1110,7 @@ class PlaybackViewModel } // Variables for tracking segment state - @VisibleForTesting + // Exposed for testing 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 45fcdd4c..ba77d8b7 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,7 +22,6 @@ 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 @@ -123,10 +122,6 @@ sealed interface PlaybackAction { data object Previous : PlaybackAction data object Next : PlaybackAction - - data class ChangeBitrate( - val maxBitrate: Long, - ) : PlaybackAction } /** @@ -566,19 +561,12 @@ 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) { - bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView() - focusRequesters.getOrNull(initialIndex)?.tryRequestFocus() + choices.indexOfFirstOrNull { it == currentChoice }?.let { + focusRequesters.getOrNull(it)?.tryRequestFocus() + } } } // TODO enforcing a width ends up ignore the gravity @@ -603,7 +591,6 @@ fun BottomDialog( ), ) { LazyColumn( - state = listState, modifier = Modifier .fillMaxWidth() @@ -637,10 +624,7 @@ fun BottomDialog( } }, interactionSource = interactionSource, - modifier = - Modifier - .bringIntoViewRequester(bringIntoViewRequesters[index]) - .focusRequester(focusRequesters[index]), + modifier = Modifier.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 81f4833b..0a843dcb 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,13 +1,6 @@ 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 @@ -45,53 +38,9 @@ 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 0e388213..a21405c2 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,12 +36,10 @@ 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 @@ -53,7 +51,6 @@ 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 @@ -77,11 +74,9 @@ 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.SeerrDialog -import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode +import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -103,7 +98,6 @@ 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() } @@ -116,11 +110,6 @@ 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() @@ -166,7 +155,6 @@ fun PreferencesContent( PreferenceScreenOption.SCREENSAVER -> screensaverPreferences PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences - PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences } val screenTitle = when (preferenceScreenOption) { @@ -177,7 +165,6 @@ 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) } @@ -237,19 +224,6 @@ 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() } @@ -285,7 +259,6 @@ fun PreferencesContent( ) } } - prefList.forEachIndexed { groupIndex, group -> item { Text( @@ -502,11 +475,10 @@ fun PreferencesContent( title = stringResource(pref.title), onClick = { seerrDialogMode = - when (val conn = seerrConnection.connection) { + when (val conn = seerrConnection) { is SeerrConnectionStatus.Error -> { SeerrDialogMode.Error( - conn.server, - conn.user, + conn.server.url, conn.ex, ) } @@ -517,20 +489,22 @@ fun PreferencesContent( is SeerrConnectionStatus.Success -> { SeerrDialogMode.Remove( - conn.current, + conn.current.server.url, ) } } }, modifier = focusModifier, summary = - when (seerrConnection.connection) { + when (seerrConnection) { is SeerrConnectionStatus.Error -> { stringResource(R.string.voice_error_server) } SeerrConnectionStatus.NotConfigured -> { - stringResource(R.string.add_server) + stringResource( + R.string.add_server, + ) } is SeerrConnectionStatus.Success -> { @@ -725,14 +699,63 @@ 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 + }, + ) + } - SeerrDialog( - seerrDialogMode = seerrDialogMode, - seerrConnection = seerrConnection, - onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, - seerrVm = seerrVm, - ) + 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 -> {} + } + } if (showQuickConnectDialog) { val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending) @@ -815,7 +838,6 @@ fun PreferencesPage( PreferenceScreenOption.SCREENSAVER, PreferenceScreenOption.SKIP_SEGMENTS, PreferenceScreenOption.EXPERIMENTAL, - PreferenceScreenOption.HDR_FORMATS, -> { PreferencesContent( initialPreferences, @@ -835,3 +857,18 @@ 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 2e0ce392..200bac4e 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,7 +12,6 @@ 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 @@ -26,7 +25,6 @@ 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 @@ -35,7 +33,6 @@ 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 @@ -79,23 +76,6 @@ 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 bd14c38c..81cc7c2a 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,7 +32,6 @@ 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 @@ -178,12 +177,7 @@ fun ServerIconCard( Icon( imageVector = Icons.Default.Warning, contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message, - tint = - if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) { - AppColors.GoldenYellow - } else { - MaterialTheme.colorScheme.errorContainer - }, + tint = 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 dd972f9b..c25fe776 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,7 +11,6 @@ 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 @@ -67,12 +66,11 @@ 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 @@ -103,8 +101,6 @@ 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) @@ -148,13 +144,10 @@ private fun SwitchServerContentInternal( val firstServerFocus = remember { FocusRequester() } LaunchedEffect(state.loading) { val state = state - when (state.loading) { - is LoadingState.Error, - LoadingState.Loading, - LoadingState.Pending, - -> Unit - - LoadingState.Success -> focusRequesters.firstOrNull()?.tryRequestFocus() + if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) { + firstServerFocus.tryRequestFocus() + } else if (state.loading == LoadingState.Pending) { + firstServerFocus.tryRequestFocus() } } LazyRow( @@ -195,7 +188,10 @@ private fun SwitchServerContentInternal( Modifier .onFocusChanged { if (it.isFocused) focusedIndex = index - }.focusRequester(focusRequesters[index]), + }.ifElse( + index == 0, + Modifier.focusRequester(firstServerFocus), + ), ) } // Add Server card - always rightmost @@ -206,7 +202,10 @@ private fun SwitchServerContentInternal( Modifier .onFocusChanged { if (it.isFocused) focusedIndex = -1 - }.focusRequester(focusRequesters.last()), + }.ifElse( + state.servers.isEmpty(), + Modifier.focusRequester(firstServerFocus), + ), ) } } @@ -236,7 +235,20 @@ private fun SwitchServerContentInternal( .fillMaxWidth() .align(Alignment.BottomCenter), ) { message -> - SwitchErrorMessage(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), + ) } // Delete server dialog @@ -315,12 +327,12 @@ private fun AddServerDialog( } } - val buttonFocusRequester = remember { FocusRequester() } + val firstDiscoveredServerFocusRequester = remember { FocusRequester() } // Default focus to first discovered server if available - LaunchedEffect(showEnterAddress) { - if (!showEnterAddress) { - buttonFocusRequester.tryRequestFocus() + LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) { + if (!showEnterAddress && state.discoveredServers.isNotEmpty()) { + firstDiscoveredServerFocusRequester.tryRequestFocus() } } @@ -367,8 +379,12 @@ private fun AddServerDialog( key = { state.discoveredServers[it].url }, ) { index -> val server = state.discoveredServers[index] - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { if (index == 0) focusRequester.tryRequestFocus() } + val focusRequester = + if (index == 0) { + firstDiscoveredServerFocusRequester + } else { + remember { FocusRequester() } + } ListItem( enabled = true, @@ -475,38 +491,21 @@ fun serverErrorMessage( server: ServerState?, resources: Resources, ): String? = - 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, - ) - } + when { + server?.status is ServerConnectionStatus.Error -> { + server.status.message + } -@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), - ) + 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 + } } -} 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 00beccb3..53190665 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,8 +97,6 @@ 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 } @@ -309,5 +307,4 @@ 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 cfe70890..86cf718d 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,6 +31,7 @@ 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 @@ -49,7 +50,6 @@ 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,15 +183,37 @@ fun SwitchUserContent( modifier = Modifier.fillMaxWidth(), ) } - val resources = LocalResources.current - val message = - remember(resources, state.serverVersionSupported) { - resources.serverSupportedMessage( - state.serverVersionSupported, - state.serverVersion, + 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), ) } - 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 b11b0b19..e1a6dd96 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,18 +300,16 @@ 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) { - serverVersion to ServerVersionSupported.UNKNOWN + systemInfo.version to ServerVersionSupported.UNKNOWN } else if (serverVersion < WholphinApplication.minimumServerVersion) { - serverVersion to ServerVersionSupported.NOT_SUPPORTED - } else if (serverVersion < WholphinApplication.newMinimumServerVersion) { - serverVersion to ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE + systemInfo.version to ServerVersionSupported.NOT_SUPPORTED } else { - serverVersion to ServerVersionSupported.SUPPORTED + systemInfo.version to ServerVersionSupported.SUPPORTED } } @@ -343,7 +341,7 @@ data class SwitchUserState( // LoadingState for while adding/switching users val switchUserState: LoadingState = LoadingState.Pending, val loginAttempts: Int = 0, - val serverVersion: ServerVersion? = null, + val serverVersion: String? = 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 5faccc05..59c9b2ae 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,7 +42,6 @@ import com.github.damontecres.wholphin.util.LoadingState @Composable fun AddSeerrServerApiKey( - initialUrl: String, onSubmit: (url: String, apiKey: String) -> Unit, status: LoadingState, modifier: Modifier = Modifier, @@ -56,18 +55,12 @@ fun AddSeerrServerApiKey( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } + var url by remember { mutableStateOf(seerrUrlPrefill()) } var apiKey by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (url.isBlank()) { - focusRequester.tryRequestFocus() - } else { - passwordFocusRequester.tryRequestFocus() - } - } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Text( text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, @@ -160,7 +153,6 @@ fun AddSeerrServerApiKey( @Composable fun AddSeerrServerUsername( - initialUrl: String, onSubmit: (url: String, username: String, password: String) -> Unit, username: String, status: LoadingState, @@ -175,22 +167,14 @@ fun AddSeerrServerUsername( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } + var url by remember { mutableStateOf(seerrUrlPrefill()) } 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) { - val focusRequesterToUse = - when { - url.isNotNullOrBlank() && username.isNotNullOrBlank() -> passwordFocusRequester - url.isNotNullOrBlank() -> usernameFocusRequester - else -> focusRequester - } - focusRequesterToUse.tryRequestFocus() - } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Text( text = stringResource(R.string.username_or_password), style = MaterialTheme.typography.titleMedium, @@ -334,7 +318,6 @@ 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 5be93b83..bb38aabb 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,161 +1,27 @@ 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, @@ -176,7 +42,6 @@ fun AddSeerServerDialog( properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( - initialUrl = initialUrl ?: "", onSubmit = { url, username, password -> onSubmit.invoke(url, username, password, auth) }, @@ -192,7 +57,6 @@ 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 2fde3eae..773f6c74 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,13 +3,11 @@ 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 @@ -17,7 +15,6 @@ 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 @@ -31,29 +28,11 @@ 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 - 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) } - } - } - } - } + val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) fun submitServer( url: String, @@ -62,21 +41,13 @@ class SwitchSeerrViewModel authMethod: SeerrAuthMethod, ) { viewModelScope.launchIO { - _state.update { it.copy(serverConnectionStatus = LoadingState.Loading) } + serverConnectionStatus.update { LoadingState.Loading } val urls = try { createUrls(url) } catch (ex: IllegalArgumentException) { showToast(context, "Invalid URL") - _state.update { - it.copy( - serverConnectionStatus = - LoadingState.Error( - "Invalid URL", - ex, - ), - ) - } + serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } return@launchIO } Timber.v("Urls to try: %s", urls) @@ -127,7 +98,7 @@ class SwitchSeerrViewModel ) } } - _state.update { it.copy(serverConnectionStatus = LoadingState.Success) } + serverConnectionStatus.update { LoadingState.Success } } else { val message = results @@ -136,7 +107,7 @@ class SwitchSeerrViewModel "$url - ${s?.localizedMessage}" }.joinToString("\n") showToast(context, "Could not connect") - _state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) } + serverConnectionStatus.update { LoadingState.Error(message) } } } } @@ -150,31 +121,11 @@ 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() { - _state.update { it.copy(serverConnectionStatus = LoadingState.Pending) } + serverConnectionStatus.update { 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 8172b049..e74ffdf2 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,8 +20,6 @@ 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 @@ -143,16 +141,13 @@ class SlideshowViewModel ), ) serverRepository.currentUser?.let { user -> - val filter = - slideshowSettings.parentId?.let { - playbackEffectDao - .getPlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - )?.videoFilter - } + playbackEffectDao + .getPlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + )?.videoFilter if (filter != null) { Timber.v("Got filter for album %s", slideshowSettings.parentId) albumImageFilter = filter @@ -234,21 +229,12 @@ class SlideshowViewModel source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO } ChosenStreams( itemPlayback = null, - audioStc = emptyList(), - subtitleStc = emptyList(), + plc = null, itemId = image.id, source = source, videoStream = video, - audioStream = - StreamChoiceResult( - audio, - StreamChoiceReason.Unknown, - ), - subtitleStream = - StreamChoiceResult( - null, - StreamChoiceReason.Unknown, - ), + audioStream = audio, + subtitleStream = null, subtitlesDisabled = false, ) } @@ -444,7 +430,7 @@ class SlideshowViewModel fun saveGalleryFilter() { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { val vf = _imageFilter.value - if (slideshowSettings.parentId != null) { + if (vf != 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 deleted file mode 100644 index 31f4f6fa..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt +++ /dev/null @@ -1,48 +0,0 @@ -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 2f98fccc..750ba711 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,7 +22,6 @@ 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 @@ -402,25 +401,6 @@ 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) { @@ -436,7 +416,6 @@ val requestSerializersModule = subclass(GetStudiosRequest::class) subclass(GetRecordingsRequest::class) subclass(GetLiveTvChannelsRequest::class) - subclass(GetArtistsRequest::class) - subclass(GetAlbumArtistsRequest::class) + subclass(GetArtistsHandler::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 d83ae269..c334967f 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.MostRecentServer +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.DeviceModule import com.google.auto.service.AutoService @@ -13,7 +13,6 @@ 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 @@ -40,10 +39,26 @@ class CrashReportSender : ReportSender { errorContent: CrashReportData, ) { Timber.v("Attempting to send crash report") - try { - val recentServer = AppModule.mostRecentServerProvider(context).get() - val api = recentServer.createApi(context) - if (api != null) { + 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) + val obj = JSONObject() for ((key, value) in errorContent.toMap()) { obj.put(key, value) @@ -60,32 +75,13 @@ class CrashReportSender : ReportSender { """.trimIndent() + jsonStr, ).content.fileName - Timber.i("Sent report to %s, filename=%s", api.baseUrl, filename) + Timber.i("Sent report to $serverUrl, filename=$filename") } - } else { - throw ReportSenderException("Could not find valid server and/or credentials to use") + } catch (ex: Exception) { + throw ReportSenderException("Exception while sending crash report", ex) } - } catch (ex: Exception) { - throw ReportSenderException("Exception while sending crash report", ex) + } else { + throw ReportSenderException("Could not find valid server and/or credentials to use") } } } - -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 deleted file mode 100644 index b4f8e15f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt +++ /dev/null @@ -1,71 +0,0 @@ -@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 deleted file mode 100644 index 632f46c3..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt +++ /dev/null @@ -1,79 +0,0 @@ -@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 deleted file mode 100644 index 7cf50622..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt +++ /dev/null @@ -1,313 +0,0 @@ -@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 f1384a27..5c5004ac 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,6 +12,7 @@ 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 @@ -70,11 +71,10 @@ 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(Codec.Audio.AC3) - } else { audioCodec(*allowedAudioCodecs) + } else { + audioCodec(Codec.Audio.AC3) } copyTimestamps = false @@ -467,56 +467,60 @@ fun createDeviceProfile( // / HDR exclude list + // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesAv1 = buildSet { - if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) + if (jellyfinTenEleven) add("DOVIInvalid") if (!decodeAv1) { if (!supportsAV1DolbyVision) { - add(WholphinVideoRangeType.DOVI.serialName) - if (!supportsAV1HDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) - if (jellyfinTenEleven && !supportsAV1HDR10Plus) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + add(VideoRangeType.DOVI.serialName) + if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") } if (!supportsAV1HDR10Plus) { - add(WholphinVideoRangeType.HDR10_PLUS.serialName) + add(VideoRangeType.HDR10_PLUS.serialName) - if (!mediaTest.supportsAV1HDR10()) add(WholphinVideoRangeType.HDR10.serialName) + if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) } } - } - forceEnabledHdr + forceDisabledHdr + } + // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesHevc = buildSet { - if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) + if (jellyfinTenEleven) add("DOVIInvalid") if (!supportsHevcDolbyVisionEL) { - if (jellyfinTenEleven) { - add(WholphinVideoRangeType.DOVI_WITH_EL.serialName) - if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { - add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) + if (!dolbyVisionELDirectPlay) { + if (jellyfinTenEleven) { + add("DOVIWithEL") + if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus") } } if (!supportsHevcDolbyVision) { - add(WholphinVideoRangeType.DOVI.serialName) - if (!supportsHevcHDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) + add(VideoRangeType.DOVI.serialName) + if (!supportsHevcHDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) if (jellyfinTenEleven && !supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { - add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + add( + "DOVIWithHDR10Plus", + ) } } } if (!supportsHevcHDR10Plus) { - add(WholphinVideoRangeType.HDR10_PLUS.serialName) - if (!supportsHevcHDR10) add(WholphinVideoRangeType.HDR10.serialName) + add(VideoRangeType.HDR10_PLUS.serialName) + if (!supportsHevcHDR10) add(VideoRangeType.HDR10.serialName) } if (jellyfinTenEleven && KnownDefects.hevcDoviHdr10PlusBug) { - add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) - add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) + add("DOVIWithHDR10Plus") + add("DOVIWithELHDR10Plus") } - } - 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 a769c400..ab4de03e 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,15 +209,6 @@ 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 deleted file mode 100644 index bd49a6c4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt +++ /dev/null @@ -1,27 +0,0 @@ -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 55553166..0c3b5cb3 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -54,15 +54,10 @@ 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 [deprecated = true]; + bool direct_play_dolby_vision_e_l = 6; 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 { @@ -230,24 +225,18 @@ 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 { - string current_server_id = 1 [deprecated = true]; - string current_user_id = 2 [deprecated = true]; + // The currently signed in server and user IDs, mostly for restoring a session + string current_server_id = 1; + string current_user_id = 2; 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 7170a274..f4523d02 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -719,44 +719,4 @@ 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 714ae611..696ad927 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -741,19 +741,4 @@ 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 2008bf2c..8fc29c91 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -719,44 +719,4 @@ 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 85603d28..898f8a10 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -724,62 +724,4 @@ 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 f078a5f7..64d8c3b1 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őzetes + Előzetesek Előzetesek DVR Időzítés @@ -138,7 +138,6 @@ Videók Nézze élőben - %1$d éves %1$d éves Lejátszás átalakítással @@ -160,8 +159,8 @@ Egyebek - A kulisszák mögött - A kulisszák mögött + Színfalak mögött + Színfalak mögött Főcímdal @@ -201,19 +200,19 @@ %s letöltés - %s letöltések + %s letöltés %d óra - %d órák + %d óra %d elem - %d elemek + %d elem %d másodperc - %d másodpercek + %d másodperc %1$s-kor ér véget Nem található szerver @@ -235,7 +234,7 @@ Tartalom alapértelmezett méretezése Frissítés telepítése Telepített verzió - A kezdőlap sorainak maximális elemszáma + Elemek száma soronként a kezdőlapon 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 @@ -304,10 +303,10 @@ PIN-kód eltávolítása Nézetbeállítások Oszlopok - Térköz + Helyköz Képarány - Részletek megjelenítése - Képtípus + Részletek mutatása + Kép típusa Vendégszereplők Szegély mérete Tartalomhoz igazodó képfrissítési gyakoriság @@ -355,10 +354,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 @@ -373,275 +372,4 @@ 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 623ef596..d6f20d4b 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -694,20 +694,4 @@ 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 be2f53e3..fb5f37ce 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -741,42 +741,4 @@ 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 d05db022..fe4ce431 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,22 +738,4 @@ עונה %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 deleted file mode 100644 index 045e125f..00000000 --- a/app/src/main/res/values-ja/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3bb8567e..83247c34 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -763,46 +763,4 @@ 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 f65e53af..d65ac309 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -741,44 +741,4 @@ 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 ca59ccb6..f7cc6e4c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -763,43 +763,4 @@ Убрать из плейлиста Срок действия учётных данных истёк. Пожалуйста, войдите снова У вас нет доступа к серверу в данный момент - С высоким рейтингом - Форматы 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 50d2edd7..bc78a1f8 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -763,20 +763,4 @@ 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 4244a0df..3df78be2 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -1,134 +1,4 @@ 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 002d5ece..3b71517a 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -686,9 +686,4 @@ 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 70804da5..f591c3fb 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 - %1$s ögesini seçin + Seç: %1$s Resim önbelleğini temizle Koleksiyon Koleksiyonlar @@ -718,45 +718,4 @@ 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 c82ff0d8..7c10daab 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -697,36 +697,4 @@ 從播放清單中移除 您目前無存取此伺服器的權限 登入憑證已過期,請重新登入 - - %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 3783bfdf..84ad72c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -154,7 +154,6 @@ Switch %1$s left Top Rated Unwatched - Top Rated Trailers DVR Schedule Guide @@ -175,7 +174,7 @@ Video Scale Video Videos - Watch live + Watch liveg %1$d years old %1$d year old @@ -415,19 +414,6 @@ 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 @@ -504,8 +490,6 @@ 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 @@ -808,11 +792,6 @@ Creator Artist Album artist - Artist source - - @string/artist - @string/album_artist - Album Popular songs Play next @@ -941,8 +920,6 @@ 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 @@ -976,16 +953,4 @@ %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/data/model/BaseItemSubtitleTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt deleted file mode 100644 index 11006322..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 283ee8ca..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index d179c7e1..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt +++ /dev/null @@ -1,207 +0,0 @@ -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 2bcc6ab7..ee32c7dd 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.RestoredSession +import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences @@ -20,6 +20,7 @@ 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 @@ -41,6 +42,7 @@ 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() @@ -62,6 +64,7 @@ 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) { @@ -79,11 +82,11 @@ class IntentServiceTest { private fun setupAutoSignInUnprotected() { setupPreferences { signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.Success(currentUser) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser } @Test @@ -95,7 +98,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertNull(result) - coVerify { serverRepository.restoreLastSession() } + coVerify { serverRepository.restoreSession(serverId, userId) } } @Test @@ -103,17 +106,17 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = false + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.Success(currentUser) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } } @Test @@ -121,17 +124,17 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.ServerOnly(protectedCurrentUser.server) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } } @Test @@ -139,17 +142,17 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.ServerOnly(protectedCurrentUser.server) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 1) { serverRepository.restoreLastSession() } + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } } @Test @@ -157,16 +160,17 @@ 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.tryChangeUser(serverId, userId) } + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } } @Test @@ -174,11 +178,12 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true + currentServerId = "" + currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.Success(currentUser) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser val intent = Intent().apply { @@ -188,7 +193,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertNull(result) - coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } } @Test @@ -196,11 +201,12 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true + currentServerId = "" + currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null -// coEvery { serverRepository.restoreLastSession() } returns -// RestoredSession.ServerOnly(protectedCurrentUser.server) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser val intent = Intent().apply { @@ -210,7 +216,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } } @Test @@ -218,9 +224,12 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true + currentServerId = "" + currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null + coEvery { serverRepository.restoreSession(serverId, userId) } returns null val intent = Intent().apply { @@ -230,7 +239,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } } @Test @@ -287,11 +296,12 @@ class IntentServiceTest { every { serverRepository.current } returns MutableStateFlow(currentUser) coEvery { - serverRepository.tryChangeUser( + serverRepository.restoreSession( newServerId, newUserId, ) } returns newCurrentUser + every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user val intent = Intent().apply { @@ -306,8 +316,9 @@ class IntentServiceTest { assertEquals(0, destinations.size) assertTrue(result.addHomeToBackStack) - coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } - coVerify { serverRepository.tryChangeUser(newServerId, newUserId) } + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + verify { serverDao.getUser(newServerId, newUserId) } + coVerify { serverRepository.restoreSession(newServerId, newUserId) } coVerify(exactly = 0) { userLibraryApi.getItem(itemId) } } @@ -322,11 +333,12 @@ class IntentServiceTest { every { serverRepository.current } returns MutableStateFlow(currentUser) coEvery { - serverRepository.tryChangeUser( + serverRepository.restoreSession( newServerId, newUserId, ) } returns newCurrentUser + every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user val intent = Intent().apply { @@ -346,8 +358,9 @@ class IntentServiceTest { assertEquals(itemId, first.itemId) - coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } - coVerify { serverRepository.tryChangeUser(newServerId, newUserId) } + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + verify { serverDao.getUser(newServerId, newUserId) } + coVerify { serverRepository.restoreSession(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 2c2fdffe..499208d6 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,13 +1,11 @@ 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 @@ -16,7 +14,6 @@ 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 @@ -24,7 +21,6 @@ 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 @@ -33,6 +29,7 @@ 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 @@ -51,8 +48,6 @@ 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 @@ -85,13 +80,14 @@ class ServerRepositoryTest { private val mockUserApi = mockk() private val mockSystemApi = mockk() - private val mockMostRecentServerProvider = mockk() + private val mockSharedPreferences = mockk(relaxed = true) @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) @@ -106,15 +102,9 @@ 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) @@ -129,8 +119,8 @@ class ServerRepositoryTest { mockJellyfin, mockJellyfinServerDao, mockApiClient, + dataStore, testDispatcher, - mockMostRecentServerProvider, ) private val serverId = UUID.randomUUID() @@ -204,24 +194,24 @@ class ServerRepositoryTest { Assert.assertEquals(user, serverRepository.currentUser) Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) - coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } 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) - val result = serverRepository.tryChangeUser(server.id, user.id) - assertTrue(result != null) - assertEquals(serverId, result?.server?.id) - assertEquals(user.id, result?.user?.id) + serverRepository.restoreSession(server.id, user.id) verify(exactly = 1) { mockApiClient.update( @@ -236,6 +226,34 @@ 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 { @@ -290,7 +308,6 @@ class ServerRepositoryTest { ) } verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } - coVerify { mockMostRecentServerProvider.clearUser() } } @Test @@ -334,7 +351,6 @@ class ServerRepositoryTest { ) } verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) } - coVerify { mockMostRecentServerProvider.clear() } } @Test @@ -371,7 +387,11 @@ class ServerRepositoryTest { Assert.assertEquals(user, serverRepository.currentUser) Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) - coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } } @Test @@ -410,7 +430,11 @@ class ServerRepositoryTest { Assert.assertEquals(user, serverRepository.currentUser) Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) - coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } + val appPreferences = dataStore.data.first() + Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) + Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) + + verify(exactly = 1) { mockSharedPreferences.edit() } } @Test @@ -460,99 +484,4 @@ 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 d8304897..b300e09a 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,12 +1,10 @@ package com.github.damontecres.wholphin.services -import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao 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.SeriesTrackChoice -import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences @@ -525,54 +523,6 @@ 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), - ), ) } } @@ -935,7 +885,7 @@ data class TestInput( val streamAudioLang: String? = "eng", val subtitles: List, val itemPlayback: ItemPlayback? = null, - val plc: List = emptyList(), + val plc: PlaybackLanguageChoice? = null, val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE, val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE, val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, @@ -945,7 +895,7 @@ data class TestInput( private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)" -fun mockServerRepo( +private fun serverRepo( audioLang: String?, subtitleMode: SubtitlePlaybackMode?, subtitleLang: String?, @@ -968,15 +918,15 @@ fun mockServerRepo( private fun runTest(input: TestInput) { val service = StreamChoiceService( - mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), - mockk(), + serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), + mockk(), ) val result = service.chooseSubtitleStream( audioStreamLang = input.streamAudioLang, candidates = input.subtitles, itemPlayback = input.itemPlayback, - stc = input.plc, + playbackLanguageChoice = input.plc, prefs = UserPreferences( AppPreferences.getDefaultInstance(), @@ -987,7 +937,7 @@ private fun runTest(input: TestInput) { ), ), ) - Assert.assertEquals(input.expectedIndex, result.stream?.index) + Assert.assertEquals(input.expectedIndex, result?.index) } fun subtitle( @@ -995,8 +945,6 @@ fun subtitle( lang: String?, default: Boolean = false, forced: Boolean = false, - sdh: Boolean = false, - external: Boolean = false, title: String? = null, ): MediaStream = MediaStream( @@ -1004,26 +952,6 @@ 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, @@ -1050,63 +978,11 @@ private fun plc( audioLang: String? = null, subtitleLang: String? = null, subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null, - 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) - } - } +): PlaybackLanguageChoice = + PlaybackLanguageChoice( + userId = 1, + seriesId = UUID.randomUUID(), + audioLanguage = audioLang, + subtitleLanguage = subtitleLang, + subtitlesDisabled = subtitlesDisabled, + ) 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 0e5e0eba..e066e837 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,10 +5,8 @@ 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 @@ -115,16 +113,16 @@ class SuggestionsWorkerTest { val mockUser = mockk() var restored = false every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) } - coEvery { mockServerRepository.restoreLastSession() } coAnswers { + coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers { restored = true - RestoredSession.Success(currentUser()) + mockUser } every { mockPreferences.data } returns flowOf(mockPrefs()) coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult() val result = createWorker().doWork() - coVerify { mockServerRepository.restoreLastSession() } + coVerify { mockServerRepository.restoreSession(testServerId, testUserId) } 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 deleted file mode 100644 index e38479f0..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt +++ /dev/null @@ -1,85 +0,0 @@ -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 a6fe6e2a..e8c2fed1 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,9 +1,7 @@ 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 @@ -15,6 +13,7 @@ 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 @@ -62,8 +61,8 @@ class TestHomeRowSamples { ), HomeRowConfig.GetItems( name = "Episodes by date created", - filter = - HomeGetItemsFilter( + getItems = + GetItemsRequest( parentId = UUID.randomUUID(), recursive = true, isFavorite = true, @@ -78,12 +77,6 @@ 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")), - ), ) } @@ -107,7 +100,6 @@ 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) @@ -134,10 +126,12 @@ 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 = """{ @@ -155,7 +149,7 @@ class TestHomeRowSamples { ] }""" - val jsonElement = HomeSettingsService.jsonParser.parseToJsonElement(str) + val jsonElement = service.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/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt similarity index 61% rename from app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt rename to app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt index 1de5becd..556a2fc0 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt @@ -1,12 +1,14 @@ -package com.github.damontecres.wholphin +package com.github.damontecres.wholphin.test 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.RestoredSession +import com.github.damontecres.wholphin.data.JellyfinServerDao 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 @@ -17,10 +19,8 @@ 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 -import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -58,6 +58,7 @@ 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( @@ -73,7 +74,6 @@ class TestMainActivityViewModel { ) private val serverId = UUID.randomUUID() - private val server = server(serverId) private val userId = UUID.randomUUID() private val currentUser = currentUser(serverId, userId) @@ -88,6 +88,13 @@ 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 @@ -114,26 +121,22 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) -// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser -// coEvery { serverRepository.restoreLastSession() } returns -// RestoredSession.Success(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser - viewModel.handleAppStart() + viewModel.appStart(null) + advanceUntilIdle() val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.AppContent, - ) + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) destination as SetupDestination.AppContent - Assert.assertEquals(currentUser, destination.current) + assertEquals(currentUser, destination.current) } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } - coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } @Test @@ -141,27 +144,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.handleAppStart() + viewModel.appStart(null) + advanceUntilIdle() - coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } - coVerify(exactly = 1) { serverRepository.getMostRecentServer() } + verify(exactly = 1) { serverDao.getServer(serverId) } + coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) } val args = mutableListOf() verify { setupNavigationManager.navigateTo(capture(args)) } - Assert.assertEquals(1, args.size) + assertEquals(2, args.size) args[0].let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.UserList, - ) + assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) + } + args[1].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) destination as SetupDestination.UserList - Assert.assertEquals(currentUser.server, destination.server) + assertEquals(currentUser.server, destination.server) } } @@ -170,30 +173,27 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } - every { serverRepository.current } returns - MutableStateFlow( - protectedCurrentUser, - ) - coEvery { - serverRepository.tryChangeUser(serverId, userId) - } returns null - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.ServerOnly(server) + every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser - viewModel.handleAppStart() + viewModel.appStart(null) + advanceUntilIdle() - coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + verify { serverDao.getServer(serverId) } val args = mutableListOf() verify { setupNavigationManager.navigateTo(capture(args)) } - Assert.assertEquals(1, args.size) + assertEquals(2, args.size) args[0].let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.UserList, - ) + assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) + } + args[1].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) destination as SetupDestination.UserList - Assert.assertEquals(currentUser.server, destination.server) + assertEquals(currentUser.server, destination.server) } } @@ -202,26 +202,22 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.ServerOnly(server) - coEvery { - serverRepository.tryChangeUser(serverId, userId) - } returns null + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser - viewModel.handleAppStart() + viewModel.appStart(null) + advanceUntilIdle() - coVerify(exactly = 1) { serverRepository.restoreLastSession() } + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.UserList, - ) + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) destination as SetupDestination.UserList - Assert.assertEquals(currentUser.server, destination.server) + assertEquals(currentUser.server, destination.server) } } @@ -230,49 +226,45 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = true + currentServerId = "" + currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None + coEvery { serverRepository.restoreSession(null, null) } returns null - viewModel.handleAppStart() + viewModel.appStart(null) + advanceUntilIdle() - coVerify(exactly = 1) { serverRepository.restoreLastSession() } + coVerify(exactly = 1) { serverRepository.restoreSession(null, null) } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.ServerList, - ) + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) } } @Test - fun `Test intent result is noop, hot reload`() = + fun `Test intent result is noop`() = runTest(testDispatcher) { setupPreferences { signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.Success(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp - val result = viewModel.handleIntent(Intent()) - assertFalse(result) + viewModel.appStart(Intent()) + advanceUntilIdle() -// 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() } + 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) + } } @Test @@ -280,22 +272,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") - val result = viewModel.handleIntent(Intent()) - assertTrue(result) + viewModel.appStart(Intent()) + advanceUntilIdle() val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.UserList, - ) + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) destination as SetupDestination.UserList - Assert.assertEquals(currentUser.server, destination.server) + assertEquals(currentUser.server, destination.server) } } @@ -304,20 +296,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") - val result = viewModel.handleIntent(Intent()) - assertTrue(result) + viewModel.appStart(Intent()) + advanceUntilIdle() val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.ServerList, - ) + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) } } @@ -326,9 +318,11 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) -// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Target( destinations = listOf(Destination.Favorites), @@ -337,27 +331,21 @@ class TestMainActivityViewModel { val backstack = mutableListOf() every { navigationManager.backStack } returns backstack - val result = viewModel.handleIntent(Intent()) - assertTrue(result) + viewModel.appStart(Intent()) + advanceUntilIdle() val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } verify { navigationManager.reloadHome() } slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.AppContent, - ) + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) destination as SetupDestination.AppContent - Assert.assertEquals(currentUser, destination.current) + assertEquals(currentUser, destination.current) } // Note: navigation manager internally handles Home destination, so it won't be in this list - Assert.assertEquals(1, backstack.size) + assertEquals(1, backstack.size) backstack[0].let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is Destination.Favorites, - ) + assertTrue("destination is ${destination::class}", destination is Destination.Favorites) } } @@ -366,9 +354,11 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) -// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Target( destinations = listOf(Destination.Favorites), @@ -377,26 +367,20 @@ class TestMainActivityViewModel { val backstack = mutableListOf() every { navigationManager.backStack } returns backstack - val result = viewModel.handleIntent(Intent()) - assertTrue(result) + viewModel.appStart(Intent()) + advanceUntilIdle() val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } verify(exactly = 0) { navigationManager.reloadHome() } slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.AppContent, - ) + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) destination as SetupDestination.AppContent - Assert.assertEquals(currentUser, destination.current) + assertEquals(currentUser, destination.current) } - Assert.assertEquals(1, backstack.size) + assertEquals(1, backstack.size) backstack[0].let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is Destination.Favorites, - ) + assertTrue("destination is ${destination::class}", destination is Destination.Favorites) } } @@ -405,9 +389,11 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) -// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Target( destinations = listOf(Destination.Favorites), @@ -416,17 +402,14 @@ class TestMainActivityViewModel { val backstack = mutableListOf() every { navigationManager.backStack } returns backstack - val result = viewModel.handleIntent(Intent()) - assertTrue(result) + viewModel.appStart(Intent()) + advanceUntilIdle() val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } verify(exactly = 0) { navigationManager.reloadHome() } slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.ServerList, - ) + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) } } } 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 deleted file mode 100644 index e58d8aec..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt +++ /dev/null @@ -1,534 +0,0 @@ -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 deleted file mode 100644 index d2013f19..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt +++ /dev/null @@ -1,44 +0,0 @@ -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 c291d067..34d962da 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,6 +11,7 @@ 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 @@ -148,16 +149,3 @@ 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 b266bb96..c4a54ec2 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,18 +17,15 @@ 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 @@ -204,10 +201,6 @@ object TestModule { fun seerrApi( @StandardOkHttpClient okHttpClient: OkHttpClient, ): SeerrApi = mockk() - - @Provides - @Singleton - fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider() } @Module @@ -267,10 +260,6 @@ 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 ba7f1e14..bc879d24 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,7 +9,6 @@ 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 @@ -35,7 +34,6 @@ 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 @@ -59,7 +57,6 @@ class CollectionFolderViewModelTest { private val mockUserLibraryApi = mockk() private val artistsRequest = slot() - private val albumArtistsRequest = slot() private val itemsRequest = slot() private val libraryId = UUID.randomUUID() @@ -86,14 +83,11 @@ 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) } @@ -103,36 +97,33 @@ class CollectionFolderViewModelTest { WholphinDispatchers.reset() unmockkObject(GetItemsRequestHandler) unmockkObject(GetArtistsHandler) - unmockkObject(GetAlbumArtistsHandler) unmockkObject(GetPersonsHandler) } - 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, - ) + 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, + ) /** Counting via /Items would return every song and album, overshooting the artist grid */ @Test @@ -152,37 +143,6 @@ 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". @@ -237,7 +197,6 @@ 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 deleted file mode 100644 index 511dbaa2..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt +++ /dev/null @@ -1,276 +0,0 @@ -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 87e5670a..21c6179b 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,7 +11,6 @@ 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 @@ -30,8 +29,6 @@ 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 @@ -67,7 +64,6 @@ 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 @@ -205,12 +201,7 @@ class PlaybackViewModelTests { coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource - coEvery { - mockStreamChoiceService.getSeriesTrackChoices( - any(), - any(), - ) - } returns emptyList() + coEvery { mockStreamChoiceService.getPlaybackLanguageChoice(any()) } returns null coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns PlayerCreation(mockPlayer) every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true) @@ -223,26 +214,18 @@ class PlaybackViewModelTests { any(), any(), ) - } returns - StreamChoiceResult( - mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }, - StreamChoiceReason.Unknown, - ) + } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO } coEvery { mockStreamChoiceService.chooseSubtitleStream( - item = any(), source = mediaSource, - audioStream = any(), - itemPlayback = any(), - stc = any(), - prefs = any(), - ) - } returns - StreamChoiceResult( - mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE }, - StreamChoiceReason.Unknown, + any(), + any(), + any(), + any(), + any(), ) + } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE } } @OptIn(ExperimentalCoroutinesApi::class) @@ -292,49 +275,6 @@ 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 deleted file mode 100644 index 483fa3ef..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index d888be15..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -@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 deleted file mode 100644 index b5c6189c..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt +++ /dev/null @@ -1,293 +0,0 @@ -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 deleted file mode 100644 index 491280df..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt +++ /dev/null @@ -1,141 +0,0 @@ -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 394aa9cd..ec4e9d24 100644 --- a/docker/README.md +++ b/docker/README.md @@ -27,27 +27,6 @@ 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 9d1d45b2..964b4534 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,23 +1,23 @@ [versions] -aboutLibraries = "15.3.0" -acra = "5.14.2" -agp = "9.5.0" +aboutLibraries = "15.2.0" +acra = "5.13.1" +agp = "9.2.1" 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.21" +kotlin = "2.4.20" ksp = "2.3.9" -coreKtx = "1.19.1" +coreKtx = "1.19.0" appcompat = "1.8.0" composeBom = "2026.09.00" mockk = "1.14.11" profileinstaller = "1.4.1" robolectric = "4.17" -multiplatformMarkdownRenderer = "0.46.0" -okhttpBom = "5.5.1" +multiplatformMarkdownRenderer = "0.45.0" +okhttpBom = "5.5.0" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" @@ -26,17 +26,15 @@ tvMaterial = "1.1.0-alpha01" lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" androidx-media3 = "1.11.1" -# Dolby Vision profile 7 -> 8.1 RPU rewrite (libdovi behind JNI), MIT -exoplayerhdrutils = "0.4.1" -coil = "3.6.3" +coil = "3.6.2" 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.12.0" -protobuf-javalite = "4.36.2" +kotlinx-serialization = "1.11.0" +protobuf-javalite = "4.36.1" hilt = "2.60.1" room = "2.8.5" preferenceKtx = "1.2.1" @@ -46,7 +44,7 @@ paletteKtx = "1.0.0" libass-android = "0.5.1" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" -openapi-generator = "7.26.0" +openapi-generator = "7.25.0" runner = "1.7.0" wholphin-extensions = "0.2.4" junit = "4.13.2" @@ -120,7 +118,6 @@ 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 9fa7f288..b1b8ef56 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 91c4fc64..a9db1155 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.8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew.bat b/gradlew.bat index 03754c4c..8508ef68 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,33 +26,6 @@ @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 @@ -72,14 +45,13 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -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. +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 "%COMSPEC%" /c exit 1 -goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -87,14 +59,13 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -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. +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 "%COMSPEC%" /c exit 1 -goto exitWithErrorLevel :execute @rem Setup the command line @@ -104,9 +75,8 @@ goto exitWithErrorLevel @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 & goto 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 -@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%