diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index 24711c4a..7cd04ce3 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -6,11 +6,11 @@ runs:
steps:
# Setup the SDKs
- name: Set up Python
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
+ uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.14'
- name: Setup JDK
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
+ uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6
with:
distribution: zulu
java-version: '21'
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 3bd2e54d..cafd4889 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -77,7 +77,7 @@ jobs:
echo "SHA256 checksums:"
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
- name: Upload mapping files
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: mapping
path: |
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 9e518406..e423cccf 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -20,7 +20,7 @@ jobs:
with:
fetch-depth: 0 # Need the tags to build
- name: Setup Python
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
+ uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.14'
- name: Run pre-commit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4822ce7e..5b108ce5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -59,14 +59,14 @@ jobs:
echo "SHA256 checksums:"
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
- name: Upload AAB
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: AAB
path: |
app/build/outputs/bundle/**/*.aab
compression-level: 0
- name: Upload mapping files
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: mapping
path: |
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6178f8ea..96883e40 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -315,6 +315,7 @@ dependencies {
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.ui.compose)
implementation(libs.ass.media)
+ implementation(libs.exoplayerhdrutils)
implementation(libs.coil.core)
implementation(libs.coil.compose)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index cddfb043..b2a0c548 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -22,3 +22,6 @@
-keep class androidx.media3.ui.SubtitlePainter {
private final float outlineWidth;
}
+
+# Keep external/native functions
+-keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; }
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json
new file mode 100644
index 00000000..3e874e4e
--- /dev/null
+++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json
@@ -0,0 +1,834 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 40,
+ "identityHash": "b297c087485321c3ee8282ad1ff675a0",
+ "entities": [
+ {
+ "tableName": "servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUsed",
+ "columnName": "lastUsed",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "rowId",
+ "columnName": "rowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessToken",
+ "columnName": "accessToken",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pin",
+ "columnName": "pin",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "requireLogin",
+ "columnName": "requireLogin",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "false"
+ },
+ {
+ "fieldPath": "lastUsed",
+ "columnName": "lastUsed",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "uiLanguage",
+ "columnName": "uiLanguage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "appPreferences.preferredAudioLanguage",
+ "columnName": "preferredAudioLanguage",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "appPreferences.preferredSubtitleLanguage",
+ "columnName": "preferredSubtitleLanguage",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "appPreferences.subtitleMode",
+ "columnName": "subtitleMode",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'USE_USER_PROFILE'"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_users_id_serverId",
+ "unique": true,
+ "columnNames": [
+ "id",
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
+ },
+ {
+ "name": "index_users_id",
+ "unique": false,
+ "columnNames": [
+ "id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
+ },
+ {
+ "name": "index_users_serverId",
+ "unique": false,
+ "columnNames": [
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ItemPlayback",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "rowId",
+ "columnName": "rowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "audioIndex",
+ "columnName": "audioIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subtitleIndex",
+ "columnName": "subtitleIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_ItemPlayback_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "NavDrawerPinnedItem",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "LibraryDisplayInfo",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sort",
+ "columnName": "sort",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "direction",
+ "columnName": "direction",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filter",
+ "columnName": "filter",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'{}'"
+ },
+ {
+ "fieldPath": "viewOptions",
+ "columnName": "viewOptions",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_LibraryDisplayInfo_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playback_effects",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
+ "fields": [
+ {
+ "fieldPath": "jellyfinUserRowId",
+ "columnName": "jellyfinUserRowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.rotation",
+ "columnName": "rotation",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.brightness",
+ "columnName": "brightness",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.contrast",
+ "columnName": "contrast",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.saturation",
+ "columnName": "saturation",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.hue",
+ "columnName": "hue",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.red",
+ "columnName": "red",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.green",
+ "columnName": "green",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.blue",
+ "columnName": "blue",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.blur",
+ "columnName": "blur",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "jellyfinUserRowId",
+ "itemId",
+ "type"
+ ]
+ }
+ },
+ {
+ "tableName": "PlaybackLanguageChoice",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "seriesId",
+ "columnName": "seriesId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "audioLanguage",
+ "columnName": "audioLanguage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "subtitleLanguage",
+ "columnName": "subtitleLanguage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "subtitlesDisabled",
+ "columnName": "subtitlesDisabled",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "seriesId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ItemTrackModification",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackIndex",
+ "columnName": "trackIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "delayMs",
+ "columnName": "delayMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId",
+ "trackIndex"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "seerr_servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_seerr_servers_url",
+ "unique": true,
+ "columnNames": [
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
+ }
+ ]
+ },
+ {
+ "tableName": "seerr_users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "jellyfinUserRowId",
+ "columnName": "jellyfinUserRowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "authMethod",
+ "columnName": "authMethod",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "password",
+ "columnName": "password",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "credential",
+ "columnName": "credential",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "jellyfinUserRowId",
+ "serverId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "seerr_servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "jellyfinUserRowId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "RememberedTab",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_RememberedTab_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "SeriesTrackChoice",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parentId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentType",
+ "columnName": "parentType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "language",
+ "columnName": "language",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "activation",
+ "columnName": "activation",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackFlags",
+ "columnName": "trackFlags",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "codec",
+ "columnName": "codec",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "relativeTrackIndex",
+ "columnName": "relativeTrackIndex",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "audioChannels",
+ "columnName": "audioChannels",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "parentId",
+ "type"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b297c087485321c3ee8282ad1ff675a0')"
+ ]
+ }
+}
diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json
new file mode 100644
index 00000000..4a41a052
--- /dev/null
+++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json
@@ -0,0 +1,841 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 41,
+ "identityHash": "1f2fe7912f9e3ab83b77029e07488e84",
+ "entities": [
+ {
+ "tableName": "servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUsed",
+ "columnName": "lastUsed",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', `c_homeSettingsSource` TEXT NOT NULL DEFAULT 'UNSET', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "rowId",
+ "columnName": "rowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessToken",
+ "columnName": "accessToken",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pin",
+ "columnName": "pin",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "requireLogin",
+ "columnName": "requireLogin",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "false"
+ },
+ {
+ "fieldPath": "lastUsed",
+ "columnName": "lastUsed",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "uiLanguage",
+ "columnName": "uiLanguage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "appPreferences.preferredAudioLanguage",
+ "columnName": "preferredAudioLanguage",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "appPreferences.preferredSubtitleLanguage",
+ "columnName": "preferredSubtitleLanguage",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "appPreferences.subtitleMode",
+ "columnName": "subtitleMode",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'USE_USER_PROFILE'"
+ },
+ {
+ "fieldPath": "config.homeSettingsSource",
+ "columnName": "c_homeSettingsSource",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'UNSET'"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_users_id_serverId",
+ "unique": true,
+ "columnNames": [
+ "id",
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
+ },
+ {
+ "name": "index_users_id",
+ "unique": false,
+ "columnNames": [
+ "id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
+ },
+ {
+ "name": "index_users_serverId",
+ "unique": false,
+ "columnNames": [
+ "serverId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ItemPlayback",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "rowId",
+ "columnName": "rowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceId",
+ "columnName": "sourceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "audioIndex",
+ "columnName": "audioIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subtitleIndex",
+ "columnName": "subtitleIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_ItemPlayback_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "NavDrawerPinnedItem",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "LibraryDisplayInfo",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sort",
+ "columnName": "sort",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "direction",
+ "columnName": "direction",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filter",
+ "columnName": "filter",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'{}'"
+ },
+ {
+ "fieldPath": "viewOptions",
+ "columnName": "viewOptions",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_LibraryDisplayInfo_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playback_effects",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
+ "fields": [
+ {
+ "fieldPath": "jellyfinUserRowId",
+ "columnName": "jellyfinUserRowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.rotation",
+ "columnName": "rotation",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.brightness",
+ "columnName": "brightness",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.contrast",
+ "columnName": "contrast",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.saturation",
+ "columnName": "saturation",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.hue",
+ "columnName": "hue",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.red",
+ "columnName": "red",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.green",
+ "columnName": "green",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.blue",
+ "columnName": "blue",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoFilter.blur",
+ "columnName": "blur",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "jellyfinUserRowId",
+ "itemId",
+ "type"
+ ]
+ }
+ },
+ {
+ "tableName": "PlaybackLanguageChoice",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "seriesId",
+ "columnName": "seriesId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "audioLanguage",
+ "columnName": "audioLanguage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "subtitleLanguage",
+ "columnName": "subtitleLanguage",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "subtitlesDisabled",
+ "columnName": "subtitlesDisabled",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "seriesId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "ItemTrackModification",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackIndex",
+ "columnName": "trackIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "delayMs",
+ "columnName": "delayMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId",
+ "trackIndex"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "seerr_servers",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_seerr_servers_url",
+ "unique": true,
+ "columnNames": [
+ "url"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
+ }
+ ]
+ },
+ {
+ "tableName": "seerr_users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "jellyfinUserRowId",
+ "columnName": "jellyfinUserRowId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverId",
+ "columnName": "serverId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "authMethod",
+ "columnName": "authMethod",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "password",
+ "columnName": "password",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "credential",
+ "columnName": "credential",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "jellyfinUserRowId",
+ "serverId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "seerr_servers",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "serverId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "jellyfinUserRowId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "RememberedTab",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_RememberedTab_userId_itemId",
+ "unique": true,
+ "columnNames": [
+ "userId",
+ "itemId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "SeriesTrackChoice",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parentId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentType",
+ "columnName": "parentType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "itemId",
+ "columnName": "itemId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "language",
+ "columnName": "language",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "activation",
+ "columnName": "activation",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackFlags",
+ "columnName": "trackFlags",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "codec",
+ "columnName": "codec",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "relativeTrackIndex",
+ "columnName": "relativeTrackIndex",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "audioChannels",
+ "columnName": "audioChannels",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "parentId",
+ "type"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "userId"
+ ],
+ "referencedColumns": [
+ "rowId"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1f2fe7912f9e3ab83b77029e07488e84')"
+ ]
+ }
+}
diff --git a/app/src/appstore/AndroidManifest.xml b/app/src/appstore/AndroidManifest.xml
index 8a762a28..e024ca83 100644
--- a/app/src/appstore/AndroidManifest.xml
+++ b/app/src/appstore/AndroidManifest.xml
@@ -3,5 +3,8 @@
xmlns:tools="http://schemas.android.com/tools">
+
diff --git a/app/src/firetv/AndroidManifest.xml b/app/src/firetv/AndroidManifest.xml
new file mode 100644
index 00000000..8a762a28
--- /dev/null
+++ b/app/src/firetv/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5be4d55a..aad5c5b5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,6 +15,7 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
+
{
- val current = serverRepository.current.value
- val destination =
- if (current != null) {
- SetupDestination.UserList(current.server)
- } else {
- SetupDestination.ServerList
- }
- setupNavigationManager.navigateTo(destination)
- Timber.e("Error parsing intent: %s", result.message)
- showToast(context, result.message)
- return@withLock
- }
+ handleAppUpgrade()
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error during app upgrade!")
+ showToast(
+ context,
+ "Error during app upgrade: ${ex.localizedMessage}",
+ )
+ }
- is IntentResult.Target -> {
- val current = serverRepository.current.value
- if (current != null) {
- Timber.i("Received valid intent, switching to AppContent")
-
- if (result.addHomeToBackStack) {
- navigationManager.reloadHome()
- } else {
- navigationManager.backStack.clear()
- }
- navigationManager.backStack.addAll(result.destinations)
-
- setupNavigationManager.navigateTo(
- SetupDestination.AppContent(current),
- )
- } else {
- // This should never happen, but just reset app state if it does
- setupNavigationManager.navigateTo(SetupDestination.ServerList)
- Timber.e("Error parsing intent, no user is active")
- showToast(context, "An error occurred parsing the intent")
- }
- return@withLock
- }
-
- IntentResult.NoOp,
- null,
- -> {
- // No-op, proceed below
- }
+ try {
+ val result = handleIntent(intent)
+ if (result) {
+ Timber.i("appStart was handled by intent")
+ return@withLock
}
} catch (ex: Exception) {
Timber.e(ex, "Error parsing intent")
}
try {
- val needUpgrade = appUpgradeHandler.needUpgrade()
- if (needUpgrade) {
- showToast(
- context,
- context.getString(
- R.string.updated_toast,
- appUpgradeHandler.currentVersion.toString(),
- ),
- )
- appUpgradeHandler.run()
- }
- appUpgradeHandler.copySubfont(false)
- val prefs =
- preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
- val profileProtected =
- serverRepository.current.value
- ?.user
- ?.isProtected == true
- if (prefs.signInAutomatically && !profileProtected) {
- val current =
- serverRepository.restoreSession(
- prefs.currentServerId?.toUUIDOrNull(),
- prefs.currentUserId?.toUUIDOrNull(),
- )
- if (current != null) {
- if (current.user.isProtected) {
- setupNavigationManager.navigateTo(
- SetupDestination.UserList(
- current.server,
- ),
- )
- } else {
- // Restored
- setupNavigationManager.navigateTo(
- SetupDestination.AppContent(
- current,
- ),
- )
- }
- } else {
- // Did not restore
- setupNavigationManager.navigateTo(SetupDestination.ServerList)
- }
- } else {
- setupNavigationManager.navigateTo(SetupDestination.Loading)
- backdropService.clearBackdrop()
- val currentServerId = prefs.currentServerId?.toUUIDOrNull()
- if (currentServerId != null) {
- val currentServer =
- serverRepository.serverDao.getServer(currentServerId)?.server
- if (currentServer != null) {
- setupNavigationManager.navigateTo(
- SetupDestination.UserList(
- currentServer,
- ),
- )
- } else {
- setupNavigationManager.navigateTo(SetupDestination.ServerList)
- }
- } else {
- setupNavigationManager.navigateTo(SetupDestination.ServerList)
- }
- }
+ handleAppStart()
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
@@ -582,6 +488,103 @@ class MainActivityViewModel
}
}
}
+
+ /**
+ * Handle an [Intent], navigating if needed
+ *
+ * @return true if the intent was handled, false if normal app start should proceed
+ */
+ @VisibleForTesting
+ internal suspend fun handleIntent(intent: Intent?): Boolean {
+ val result = intent?.let { intentService.parseIntent(intent) }
+ when (result) {
+ is IntentResult.Error -> {
+ val current = serverRepository.current.value
+ val destination =
+ if (current != null) {
+ SetupDestination.UserList(current.server)
+ } else {
+ SetupDestination.ServerList
+ }
+ setupNavigationManager.navigateTo(destination)
+ Timber.e("Error parsing intent: %s", result.message)
+ showToast(context, result.message)
+ return true
+ }
+
+ is IntentResult.Target -> {
+ val current = serverRepository.current.value
+ if (current != null) {
+ Timber.i("Received valid intent, switching to AppContent")
+
+ if (result.addHomeToBackStack) {
+ navigationManager.reloadHome()
+ } else {
+ navigationManager.backStack.clear()
+ }
+ navigationManager.backStack.addAll(result.destinations)
+
+ setupNavigationManager.navigateTo(
+ SetupDestination.AppContent(current),
+ )
+ } else {
+ // This should never happen, but just reset app state if it does
+ setupNavigationManager.navigateTo(SetupDestination.ServerList)
+ Timber.e("Error parsing intent, no user is active")
+ showToast(context, "An error occurred parsing the intent")
+ }
+ return true
+ }
+
+ IntentResult.NoOp,
+ null,
+ -> {
+ // Not handled
+ return false
+ }
+ }
+ }
+
+ private suspend fun handleAppUpgrade() {
+ val needUpgrade = appUpgradeHandler.needUpgrade()
+ if (needUpgrade) {
+ showToast(
+ context,
+ context.getString(
+ R.string.updated_toast,
+ appUpgradeHandler.currentVersion.toString(),
+ ),
+ )
+ appUpgradeHandler.run()
+ }
+ appUpgradeHandler.copySubfont(false)
+ }
+
+ /**
+ * Handle starting the app including auto sign in and navigating to the right starting page
+ */
+ @VisibleForTesting
+ internal suspend fun handleAppStart() {
+ val signInAutomatically = preferences.data.first().signInAutomatically
+ val currentUser = serverRepository.current.value
+ if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) {
+ // Hot reload
+ Timber.v("Hot reload restoring state")
+ setupNavigationManager.navigateTo(
+ SetupDestination.AppContent(currentUser),
+ )
+ } else {
+ val restoredSession =
+ if (signInAutomatically) {
+ Timber.v("Sign in automatically")
+ serverRepository.restoreLastSession()
+ } else {
+ serverRepository.getMostRecentServer()
+ }
+ Timber.d("Restore last session result=%s", restoredSession)
+ setupNavigationManager.navigateTo(restoredSession.destination)
+ }
+ }
}
private val Destination?.isPlayback: Boolean
diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
index f027bbae..6ec9d4b4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt
@@ -122,5 +122,7 @@ class WholphinApplication :
private set
val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion
+
+ val newMinimumServerVersion: ServerVersion = ServerVersion(12, 0, 0)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
index 57d4a447..3bc17f3c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
@@ -22,6 +22,7 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ScreensaverService
@@ -33,9 +34,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.first
import okhttp3.OkHttpClient
-import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@@ -75,8 +74,10 @@ class WholphinDreamService :
lifecycleRegistry.currentState = Lifecycle.State.CREATED
lifecycleScope.launchDefault {
if (serverRepository.current.value == null) {
- val prefs = preferencesDataStore.data.first()
- serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull())
+ val result = serverRepository.restoreLastSession()
+ if (result !is RestoredSession.Success) {
+ Timber.w("Could not restore user session")
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
index 6a691723..45af2d65 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt
@@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
@@ -48,8 +49,9 @@ import java.util.UUID
SeerrServer::class,
SeerrUser::class,
RememberedTab::class,
+ SeriesTrackChoice::class,
],
- version = 35,
+ version = 41,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@@ -68,6 +70,8 @@ import java.util.UUID
AutoMigration(32, 33),
AutoMigration(33, 34),
AutoMigration(34, 35),
+ AutoMigration(35, 40),
+ AutoMigration(40, 41),
],
)
@TypeConverters(Converters::class)
@@ -87,6 +91,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun playbackEffectDao(): PlaybackEffectDao
abstract fun rememberedTabDao(): RememberedTabDao
+
+ abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao
}
class Converters {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
index c3afbc33..702ecfdb 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt
@@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.data
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.ItemTrackModification
-import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
+import com.github.damontecres.wholphin.data.model.RelativeMediaStream
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.withContext
@@ -28,7 +31,7 @@ class ItemPlaybackRepository
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
- private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
+ private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
private val streamChoiceService: StreamChoiceService,
) {
/**
@@ -45,54 +48,79 @@ class ItemPlaybackRepository
): ChosenStreams? =
serverRepository.currentUser?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
- val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
- Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}")
- return getChosenItemFromPlayback(item, itemPlayback, plc, prefs)
+ return getChosenItemFromPlayback(item, itemPlayback, prefs)
}
+ suspend fun getChosenItemFromPlayback(
+ item: BaseItem,
+ itemPlayback: ItemPlayback?,
+ prefs: UserPreferences,
+ ): ChosenStreams? {
+ val audioStc =
+ streamChoiceService.getSeriesTrackChoices(
+ item.data,
+ SeriesTrackChoiceType.AUDIO,
+ )
+ val subtitleStc =
+ streamChoiceService.getSeriesTrackChoices(
+ item.data,
+ SeriesTrackChoiceType.SUBTITLE,
+ )
+ return getChosenItemFromPlayback(item, itemPlayback, audioStc, subtitleStc, prefs)
+ }
+
/**
* Get the chosen source & stream for the given item
*
- * This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null
+ * This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null
*/
fun getChosenItemFromPlayback(
item: BaseItem,
itemPlayback: ItemPlayback?,
- plc: PlaybackLanguageChoice?,
+ audioStc: List,
+ subtitleStc: List,
prefs: UserPreferences,
): ChosenStreams? {
+ Timber.v(
+ "For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s",
+ item.id,
+ itemPlayback != null,
+ audioStc.isNotEmpty(),
+ subtitleStc.isNotEmpty(),
+ )
val source =
item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId }
?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty())
if (source != null) {
- val audioStream =
+ val audioResult =
streamChoiceService.chooseAudioStream(
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO }
.orEmpty(),
itemPlayback = itemPlayback,
- playbackLanguageChoice = plc,
+ stc = audioStc,
prefs = prefs,
)
val subtitleStream =
streamChoiceService.chooseSubtitleStream(
- audioStreamLang = audioStream?.language,
+ audioStreamLang = audioResult.stream?.language,
candidates =
source.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
.orEmpty(),
itemPlayback = itemPlayback,
- playbackLanguageChoice = plc,
+ stc = subtitleStc,
prefs = prefs,
)
return ChosenStreams(
itemPlayback = itemPlayback,
- plc = plc,
+ audioStc = audioStc,
+ subtitleStc = subtitleStc,
itemId = item.id,
source = source,
videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO },
- audioStream = audioStream,
+ audioStream = audioResult,
subtitleStream = subtitleStream,
subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED,
)
@@ -156,26 +184,36 @@ class ItemPlaybackRepository
Timber.v("Saving track selection %s", toSave)
toSave = saveItemPlayback(toSave)
val seriesId = item.data.seriesId
- if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) {
+ if (seriesId != null || item.data.parentId != null) {
if (type == MediaStreamType.AUDIO) {
- val stream = source.mediaStreams?.first { it.index == trackIndex }
+ val stream =
+ RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
if (stream?.language != null) {
- streamChoiceService.updateAudio(item.data, stream.language!!)
+ streamChoiceService.saveSeriesTrackChoice(
+ item.data,
+ stream.type,
+ stream,
+ )
}
} else if (type == MediaStreamType.SUBTITLE) {
if (trackIndex == TrackIndex.DISABLED) {
- streamChoiceService.updateSubtitles(
+ streamChoiceService.saveDisabledSeriesTrackChoice(
item.data,
- subtitleLang = null,
- subtitlesDisabled = true,
+ MediaStreamType.SUBTITLE,
+ )
+ } else if (trackIndex == TrackIndex.ONLY_FORCED) {
+ streamChoiceService.saveOnlyForcedSeriesTrackChoice(
+ item.data,
+ MediaStreamType.SUBTITLE,
)
} else {
- val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex }
+ val stream =
+ RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty())
if (stream?.language != null) {
- streamChoiceService.updateSubtitles(
+ streamChoiceService.saveSeriesTrackChoice(
item.data,
- stream.language!!,
- subtitlesDisabled = false,
+ stream.type,
+ stream,
)
}
}
@@ -240,9 +278,10 @@ class ItemPlaybackRepository
*/
suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) {
Timber.d("deleteChosenStreams: %s", chosenStreams)
- chosenStreams?.plc?.let {
- Timber.d("Deleting %s", it)
- playbackLanguageChoiceDao.delete(it)
+ val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty()
+ if (stc.isNotEmpty()) {
+ Timber.d("Deleting %s", stc)
+ seriesTrackChoiceDao.delete(stc)
}
chosenStreams?.itemPlayback?.let {
Timber.d("Deleting %s", it)
@@ -256,11 +295,12 @@ class ItemPlaybackRepository
*/
data class ChosenStreams(
val itemPlayback: ItemPlayback?,
- val plc: PlaybackLanguageChoice?,
+ val audioStc: List,
+ val subtitleStc: List,
val itemId: UUID,
val source: MediaSourceInfo,
val videoStream: MediaStream?,
- val audioStream: MediaStream?,
- val subtitleStream: MediaStream?,
+ val audioStream: StreamChoiceResult,
+ val subtitleStream: StreamChoiceResult,
val subtitlesDisabled: Boolean,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt
new file mode 100644
index 00000000..5139fb73
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt
@@ -0,0 +1,175 @@
+package com.github.damontecres.wholphin.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import com.github.damontecres.wholphin.data.MostRecentServer.None
+import com.github.damontecres.wholphin.data.MostRecentServer.Server
+import com.github.damontecres.wholphin.data.MostRecentServer.ServerAndUser
+import com.github.damontecres.wholphin.data.model.JellyfinServer
+import com.github.damontecres.wholphin.services.SetupDestination
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
+import com.github.damontecres.wholphin.ui.toServerString
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
+import java.util.UUID
+
+/**
+ * Represents the status of the most recently used server
+ *
+ * This can be saved/retrieved from a [MostRecentServerProvider]
+ */
+sealed interface MostRecentServer {
+ /**
+ * There is no recently used server
+ */
+ data object None : MostRecentServer
+
+ /**
+ * The most recently used server without a recent user
+ */
+ data class Server(
+ val serverId: UUID,
+ val serverUrl: String,
+ ) : MostRecentServer
+
+ /**
+ * Most recently used server and user
+ */
+ data class ServerAndUser(
+ val serverId: UUID,
+ val serverUrl: String,
+ val userId: UUID,
+ val accessToken: String,
+ ) : MostRecentServer {
+ override fun toString(): String = "ServerAndUser(serverId=$serverId, serverUrl=$serverUrl, userId=$userId)"
+ }
+
+ companion object {
+ fun from(current: CurrentUser?): MostRecentServer =
+ if (current != null && current.user.accessToken.isNotNullOrBlank()) {
+ ServerAndUser(current.server.id, current.server.url, current.user.id, current.user.accessToken)
+ } else if (current != null) {
+ Server(current.server.id, current.server.url)
+ } else {
+ None
+ }
+ }
+}
+
+interface MostRecentServerProvider {
+ /**
+ * Get the [MostRecentServer] from this provider
+ */
+ fun get(): MostRecentServer
+
+ /**
+ * Save the [CurrentUser] as the most recent server
+ */
+ suspend fun save(current: CurrentUser)
+
+ /**
+ * Clear the most recent server and user
+ */
+ suspend fun clear()
+
+ /**
+ * CLear the most recent user, leaving the server
+ */
+ suspend fun clearUser()
+}
+
+/**
+ * A [MostRecentServerProvider] that stores in [SharedPreferences]
+ */
+class MostRecentServerSharedPreferences(
+ context: Context,
+) : MostRecentServerProvider {
+ private val prefs = getServerSharedPreferences(context)
+
+ override fun get(): MostRecentServer {
+ val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull()
+ val serverUrl = prefs.getString(SERVER_URL_KEY, null)
+ val userId = prefs.getString(USER_ID_KEY, null)?.toUUIDOrNull()
+ val accessToken = prefs.getString(USER_TOKEN_KEY, null)
+ return if (serverId != null && serverUrl.isNotNullOrBlank() && userId != null && accessToken.isNotNullOrBlank()) {
+ ServerAndUser(serverId, serverUrl, userId, accessToken)
+ } else if (serverId != null && serverUrl.isNotNullOrBlank()) {
+ Server(serverId, serverUrl)
+ } else {
+ None
+ }
+ }
+
+ override suspend fun save(current: CurrentUser) {
+ prefs.edit(true) {
+ putString(SERVER_ID_KEY, current.server.id.toServerString())
+ putString(SERVER_URL_KEY, current.server.url)
+ putString(USER_ID_KEY, current.user.id.toServerString())
+ putString(USER_TOKEN_KEY, current.user.accessToken)
+ }
+ }
+
+ override suspend fun clear() {
+ prefs.edit(true) {
+ remove(SERVER_ID_KEY)
+ remove(SERVER_URL_KEY)
+ remove(USER_ID_KEY)
+ remove(USER_TOKEN_KEY)
+ }
+ }
+
+ override suspend fun clearUser() {
+ prefs.edit(true) {
+ remove(USER_ID_KEY)
+ remove(USER_TOKEN_KEY)
+ }
+ }
+
+ private fun getServerSharedPreferences(context: Context): SharedPreferences =
+ context.getSharedPreferences(
+ "${context.packageName}_server",
+ Context.MODE_PRIVATE,
+ )
+
+ companion object {
+ private const val SERVER_ID_KEY = "current.server.id"
+ private const val SERVER_URL_KEY = "current.server.url"
+ private const val USER_ID_KEY = "current.user.id"
+ private const val USER_TOKEN_KEY = "current.user.accessToken"
+ }
+}
+
+/**
+ * The result of restoring a session
+ */
+sealed interface RestoredSession {
+ val destination: SetupDestination
+
+ /**
+ * Session could not be restored
+ */
+ data object None : RestoredSession {
+ override val destination: SetupDestination
+ get() = SetupDestination.ServerList
+ }
+
+ /**
+ * Only the server should be restored, so a user still need to be chosen
+ */
+ data class ServerOnly(
+ val server: JellyfinServer,
+ ) : RestoredSession {
+ override val destination: SetupDestination
+ get() = SetupDestination.UserList(server)
+ }
+
+ /**
+ * Session was restored successfully
+ */
+ data class Success(
+ val currentUser: CurrentUser,
+ ) : RestoredSession {
+ override val destination: SetupDestination
+ get() = SetupDestination.AppContent(currentUser)
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt
index 5f8c16e6..06835381 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt
@@ -8,17 +8,27 @@ import androidx.room.Query
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import java.util.UUID
+@Deprecated("Use SeriesTrackChoiceDao")
@Dao
interface PlaybackLanguageChoiceDao {
+ @Deprecated("Use SeriesTrackChoiceDao")
@Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId")
suspend fun get(
userId: Int,
seriesId: UUID,
): PlaybackLanguageChoice?
+ @Deprecated("Use SeriesTrackChoiceDao")
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(plc: PlaybackLanguageChoice): Long
+ @Deprecated("Use SeriesTrackChoiceDao")
@Delete
fun delete(plc: PlaybackLanguageChoice)
+
+ @Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset")
+ suspend fun getAll(
+ limit: Int,
+ offset: Int,
+ ): List
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt
new file mode 100644
index 00000000..b3126723
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt
@@ -0,0 +1,52 @@
+package com.github.damontecres.wholphin.data
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
+import java.util.UUID
+
+@Dao
+interface SeriesTrackChoiceDao {
+ @Query(
+ "SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND (parentId=:seasonId OR parentId=:seriesId) ORDER BY parentId=:seasonId DESC",
+ )
+ suspend fun get(
+ userId: Int,
+ seasonId: UUID,
+ seriesId: UUID,
+ type: SeriesTrackChoiceType,
+ ): List
+
+ @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seasonId")
+ suspend fun getBySeasonId(
+ userId: Int,
+ seasonId: UUID,
+ type: SeriesTrackChoiceType,
+ ): List
+
+ @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seriesId")
+ suspend fun getBySeriesId(
+ userId: Int,
+ seriesId: UUID,
+ type: SeriesTrackChoiceType,
+ ): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun save(stc: SeriesTrackChoice): Long
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun save(stc: List): List
+
+ @Delete
+ suspend fun delete(stc: List)
+
+ @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId")
+ suspend fun getAll(userId: Int): List
+
+ @Query("SELECT count(*) from SeriesTrackChoice")
+ suspend fun count(): Int
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
index 5deba4c3..2c24c8f3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt
@@ -1,15 +1,10 @@
package com.github.damontecres.wholphin.data
import android.content.Context
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.ServerUserConfig
-import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
-import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
@@ -49,8 +44,8 @@ class ServerRepository
val jellyfin: Jellyfin,
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
- val userPreferencesDataStore: DataStore,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+ private val mostRecentServerProvider: MostRecentServerProvider,
) {
private var _current = MutableStateFlow(null)
val current: StateFlow = _current
@@ -77,6 +72,8 @@ class ServerRepository
}
}
+ val serverPluginInstalled = MutableStateFlow(false)
+
/**
* Adds a server to the app database and updated the [ApiClient] to the server's URL
*
@@ -88,10 +85,11 @@ class ServerRepository
}
apiClient.update(baseUrl = server.url, accessToken = null)
_current.value = null
+ mostRecentServerProvider.clearUser()
}
/**
- * Saves the server & User to the app database and updates the [ApiClient] to use this server & user
+ * Saves the server & user to the app database and updates the [ApiClient] to use this server & user
*/
suspend fun changeUser(
server: JellyfinServer,
@@ -119,70 +117,101 @@ class ServerRepository
)
serverDao.addOrUpdateServer(updatedServer)
updatedUser = serverDao.addOrUpdateUser(updatedUser)
- userPreferencesDataStore.updateData {
- it
- .toBuilder()
- .apply {
- currentServerId = updatedServer.id.toServerString()
- currentUserId = updatedUser.id.toServerString()
- }.build()
- }
+
val currentUser = CurrentUser(updatedServer, updatedUser)
- withContext(WholphinDispatchers.Main) {
- _current.value = currentUser
- _currentUserDto.value = ServerUserConfig(userDto)
- }
- getServerSharedPreferences(context).edit(true) {
- putString(SERVER_URL_KEY, updatedServer.url)
- putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
- }
+ mostRecentServerProvider.save(currentUser)
+ _current.value = currentUser
+ _currentUserDto.value = ServerUserConfig(userDto)
return@withContext currentUser
}
/**
- * Restores a session for the given server & user such as when the app reopens
+ * Try to restore the most recent session
*
- * If user has a PIN, this returns false
+ * Does not check if auto sign-in is enabled, but does check if the user profile is protected
*/
- suspend fun restoreSession(
+ suspend fun restoreLastSession(): RestoredSession {
+ val recentServer = mostRecentServerProvider.get()
+ Timber.d("restoreLastSession: recentServer=%s", recentServer)
+ val result =
+ when (recentServer) {
+ MostRecentServer.None -> {
+ RestoredSession.None
+ }
+
+ is MostRecentServer.Server -> {
+ val server = serverDao.getServer(recentServer.serverId)
+ if (server != null) {
+ RestoredSession.ServerOnly(server.server)
+ } else {
+ RestoredSession.None
+ }
+ }
+
+ is MostRecentServer.ServerAndUser -> {
+ val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId)
+ if (currentUser != null) {
+ if (currentUser.user.isProtected) {
+ RestoredSession.ServerOnly(currentUser.server)
+ } else {
+ RestoredSession.Success(currentUser)
+ }
+ } else {
+ getMostRecentServerInternal(recentServer)
+ }
+ }
+ }
+ return result
+ }
+
+ /**
+ * Get the most recently used server, if any
+ */
+ suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get())
+
+ suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession {
+ val recentServer = recentServer ?: mostRecentServerProvider.get()
+ val serverId =
+ when (recentServer) {
+ MostRecentServer.None -> null
+ is MostRecentServer.Server -> recentServer.serverId
+ is MostRecentServer.ServerAndUser -> recentServer.serverId
+ }
+ val server = serverId?.let { serverDao.getServer(serverId)?.server }
+ return if (server != null) {
+ RestoredSession.ServerOnly(server)
+ } else {
+ RestoredSession.None
+ }
+ }
+
+ /**
+ * Try to change to the given server & user. If the user's profile is protected, returns null
+ *
+ * @return the server/user or null if the session could not be restored
+ */
+ suspend fun tryChangeUser(
serverId: UUID?,
userId: UUID?,
- ): CurrentUser? {
- if (serverId == null || userId == null) {
- _current.value = null
- return null
- }
- val serverAndUsers =
- withContext(ioDispatcher) {
- serverDao.getServer(serverId)
- }
- if (serverAndUsers != null) {
- val current = _current.value
- if (current != null && current.server.id == serverId && current.user.id == userId) {
- Timber.v("Restoring session for current user, so shortcut")
- apiClient.update(
- baseUrl = current.server.url,
- accessToken = current.user.accessToken,
- )
- return current
+ ): CurrentUser? =
+ withContext(ioDispatcher) {
+ return@withContext if (serverId == null || userId == null) {
+ _current.value = null
+ null
} else {
- val user = serverAndUsers.users.firstOrNull { it.id == userId }
- if (user != null) {
- return changeUser(serverAndUsers.server, user)
+ val serverAndUsers = serverDao.getServer(serverId)
+ if (serverAndUsers != null) {
+ val user = serverAndUsers.users.firstOrNull { it.id == userId }
+ if (user != null && !user.isProtected) {
+ changeUser(serverAndUsers.server, user)
+ } else {
+ null
+ }
+ } else {
+ null
}
}
}
- return null
- }
-
- suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
- withContext(ioDispatcher) {
- serverId?.let { serverDao.getServer(serverId)?.server }
- }
-
- fun closeSession() {
- _current.value = null
- }
/**
* Given a successful [AuthenticationResult], switch to the user that just authenticated
@@ -244,13 +273,7 @@ class ServerRepository
withContext(WholphinDispatchers.Main) {
_current.value = null
}
- userPreferencesDataStore.updateData {
- it
- .toBuilder()
- .apply {
- currentUserId = ""
- }.build()
- }
+ mostRecentServerProvider.clearUser()
apiClient.update(accessToken = null)
}
withContext(ioDispatcher) {
@@ -263,14 +286,7 @@ class ServerRepository
withContext(WholphinDispatchers.Main) {
_current.value = null
}
- userPreferencesDataStore.updateData {
- it
- .toBuilder()
- .apply {
- currentServerId = ""
- currentUserId = ""
- }.build()
- }
+ mostRecentServerProvider.clear()
apiClient.update(baseUrl = null, accessToken = null)
}
withContext(ioDispatcher) {
@@ -279,14 +295,7 @@ class ServerRepository
}
suspend fun switchServerOrUser() {
- userPreferencesDataStore.updateData {
- it
- .toBuilder()
- .apply {
- currentServerId = ""
- currentUserId = ""
- }.build()
- }
+ mostRecentServerProvider.clear()
}
suspend fun updateUserAuth(
@@ -335,17 +344,6 @@ class ServerRepository
if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it
}
}
-
- companion object {
- fun getServerSharedPreferences(context: Context): SharedPreferences =
- context.getSharedPreferences(
- "${context.packageName}_server",
- Context.MODE_PRIVATE,
- )
-
- const val SERVER_URL_KEY = "current.server"
- const val ACCESS_TOKEN_KEY = "current.accessToken"
- }
}
@Serializable
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
index 4b38d398..256ccd65 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt
@@ -66,6 +66,7 @@ data class BaseItem(
BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ")
BaseItemKind.SERIES -> data.seriesProductionYears
BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ")
+ BaseItemKind.MUSIC_ALBUM -> listOf(data.albumArtist ?: artistsString, data.productionYear?.toString()).joinNotBlank(" - ")
else -> data.productionYear?.toString()
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
index 3746092b..91228969 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt
@@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.VideoType
+import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
@@ -155,6 +156,22 @@ data class GetItemsFilter(
},
)
+ fun applyTo(req: GetAlbumArtistsRequest) =
+ req.copy(
+ minCommunityRating = minCommunityRating,
+ isFavorite = favorite,
+ genreIds = genres,
+ personIds = persons,
+ studioIds = studios,
+ tags = tags,
+ officialRatings = officialRatings,
+ years =
+ buildSet {
+ years?.letNotEmpty(::addAll)
+ decades?.forEach { addAll(it..<(it + 10)) }
+ },
+ )
+
/**
* Merge another [GetItemsFilter] onto this one, replacing only unset values
*/
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
index 4914248f..39b82bc8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt
@@ -1,4 +1,7 @@
-@file:UseSerializers(UUIDSerializer::class)
+@file:UseSerializers(
+ UUIDSerializer::class,
+ DateTimeSerializer::class,
+)
package com.github.damontecres.wholphin.data.model
@@ -7,11 +10,15 @@ import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.serializer.DateTimeSerializer
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@@ -174,19 +181,45 @@ sealed interface HomeRowConfig {
}
/**
- * An arbitrary [GetItemsRequest] allowing to query for anything
+ * An arbitrary [HomeGetItemsFilter] allowing to query for most anything
*/
@Serializable
@SerialName("GetItems")
data class GetItems(
val name: String,
- val getItems: GetItemsRequest,
+ val filter: HomeGetItemsFilter,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
}
+
+ /**
+ * Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult.
+ * Lets third-party plugins (e.g. home-sections) contribute rows without Wholphin needing
+ * specific knowledge of them.
+ *
+ * Headers/Query are a list of [KeyValueEntry] (not a Map) because the server-side plugin
+ * config is persisted as XML, which doesn't support IDictionary.
+ */
+ @Serializable
+ @SerialName("CustomEndpoint")
+ data class CustomEndpoint(
+ val endpoint: String,
+ val title: String,
+ val headers: List? = null,
+ val query: List? = null,
+ override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
+ ) : HomeRowConfig {
+ override fun updateViewOptions(viewOptions: HomeRowViewOptions): CustomEndpoint = this.copy(viewOptions = viewOptions)
+ }
}
+@Serializable
+data class KeyValueEntry(
+ val key: String,
+ val value: String,
+)
+
/**
* Root class for home page settings
*
@@ -241,3 +274,54 @@ data class HomeRowViewOptions(
)
}
}
+
+/**
+ * Subset of [GetItemsFilter]
+ */
+@Serializable
+data class HomeGetItemsFilter(
+ val sortBy: List? = null,
+ val sortOrder: List? = null,
+ val ids: List? = null,
+ val minCommunityRating: Double? = null,
+ val minCriticRating: Double? = null,
+ val recursive: Boolean? = null,
+ val searchTerm: String? = null,
+ val parentId: UUID? = null,
+ val includeItemTypes: List? = null,
+ val isFavorite: Boolean? = null,
+ val isPlayed: Boolean? = null,
+ val officialRatings: Collection? = null,
+ val tags: List? = null,
+ val years: List? = null,
+ val personIds: List? = null,
+ val minOfficialRating: String? = null,
+ val hasOfficialRating: Boolean? = null,
+ val studioIds: List? = null,
+ val genreIds: List? = null,
+ val minPremiereDate: LocalDateTime? = null,
+ val maxPremiereDate: LocalDateTime? = null,
+) {
+ fun asGetItemsRequest() =
+ GetItemsRequest(
+ sortBy = sortBy,
+ sortOrder = sortOrder,
+ ids = ids,
+ minCommunityRating = minCommunityRating,
+ minCriticRating = minCriticRating,
+ recursive = recursive,
+ searchTerm = searchTerm,
+ parentId = parentId,
+ includeItemTypes = includeItemTypes,
+ isFavorite = isFavorite,
+ isPlayed = isPlayed,
+ officialRatings = officialRatings,
+ tags = tags,
+ years = years,
+ personIds = personIds,
+ minOfficialRating = minOfficialRating,
+ hasOfficialRating = hasOfficialRating,
+ studioIds = studioIds,
+ genreIds = genreIds,
+ )
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
index 4c54311c..6c5a2a58 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt
@@ -13,6 +13,7 @@ import androidx.room.Relation
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserProfileSettings
+import com.github.damontecres.wholphin.services.HomePageSettingsSource
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
@@ -68,6 +69,7 @@ data class JellyfinUser(
val lastUsed: ZonedDateTime? = null,
val uiLanguage: String? = null,
@Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(),
+ @Embedded(prefix = "c_") val config: JellyfinUserConfig = JellyfinUserConfig(),
) {
val hasPin: Boolean get() = pin.isNotNullOrBlank()
@@ -77,9 +79,17 @@ data class JellyfinUser(
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +
"requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " +
- "appPreferences=$appPreferences"
+ "appPreferences=$appPreferences, config=$config"
+
+ fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config))
}
+@Serializable
+data class JellyfinUserConfig(
+ @ColumnInfo(defaultValue = "UNSET")
+ val homeSettingsSource: HomePageSettingsSource = HomePageSettingsSource.UNSET,
+)
+
/**
* Represents the relationship between [JellyfinServer] and its [JellyfinUser]
*/
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
index 4e639009..a20a2dcd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt
@@ -35,7 +35,7 @@ data class Person(
name = dto.name,
role = personRole(context, dto.role, dto.type),
type = dto.type,
- imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
+ imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
favorite = false,
)
@@ -50,7 +50,7 @@ data class Person(
name = dto.name,
role = personRole(context, dto.role, dto.type),
type = dto.type,
- imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
+ imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
favorite = favorite,
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
index fb54dfcd..f5507a50 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt
@@ -10,9 +10,14 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
/**
+ * DEPRECATED
+ *
* Stores the language choices for a series so they can be applied automatically to other episodes
* without the user needing to explicitly choose the tracks
+ *
+ * @see SeriesTrackChoice
*/
+@Deprecated("Use SeriesTrackChoice")
@Entity(
foreignKeys = [
ForeignKey(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt
new file mode 100644
index 00000000..cd2fece7
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt
@@ -0,0 +1,61 @@
+package com.github.damontecres.wholphin.data.model
+
+import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
+import org.jellyfin.sdk.model.api.MediaStream
+import org.jellyfin.sdk.model.api.MediaStreamType
+
+/**
+ * A [MediaStream] with its relative track index, its position within a specific stream type
+ */
+data class RelativeMediaStream(
+ val relativeIndex: Int,
+ val stream: MediaStream,
+) {
+ val type: MediaStreamType get() = stream.type
+ val language: String? get() = stream.language
+ val isForced: Boolean get() = stream.isForced
+ val isExternal: Boolean get() = stream.isExternal
+ val isDefault: Boolean get() = stream.isDefault
+ val isHearingImpaired: Boolean get() = stream.isHearingImpaired
+
+ companion object {
+ fun getRelativeIndex(
+ index: Int,
+ streams: List,
+ ): Int? {
+ val stream = streams.firstOrNull { it.index == index } ?: return null
+ return getRelativeIndex(stream, streams)
+ }
+
+ fun getRelativeIndex(
+ stream: MediaStream,
+ streams: List,
+ ): Int? =
+ streams
+ .filter { it.type == stream.type && it.isExternal == stream.isExternal }
+ .sortedBy { it.index }
+ .indexOfFirstOrNull { it.index == stream.index }
+
+ fun from(
+ index: Int,
+ streams: List,
+ ): RelativeMediaStream? {
+ val stream = streams.firstOrNull { it.index == index } ?: return null
+ val relativeIndex = getRelativeIndex(stream, streams) ?: return null
+ return RelativeMediaStream(relativeIndex, stream)
+ }
+ }
+}
+
+/**
+ * Maps a list of [MediaStream] into [RelativeMediaStream]
+ */
+fun List.asRelative(): List {
+ val map = this.groupBy { Pair(it.type, it.isExternal) }
+ return map.values
+ .flatMap { typedStreams ->
+ typedStreams.sortedBy { it.index }.mapIndexed { index, stream ->
+ RelativeMediaStream(index, stream)
+ }
+ }.sortedBy { it.stream.index }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
index aafdebf7..918384d4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt
@@ -54,3 +54,11 @@ fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
permission.parents.any { hasPermission(it) } ||
SeerrPermission.ADMIN.hasPermission(permissions)
}
+
+val SeerrUserConfig?.canRequestMovies: Boolean
+ get() =
+ hasPermission(SeerrPermission.REQUEST_MOVIE) || hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
+
+val SeerrUserConfig?.canRequestTv: Boolean
+ get() =
+ hasPermission(SeerrPermission.REQUEST_TV) || hasPermission(SeerrPermission.REQUEST_4K_TV)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt
new file mode 100644
index 00000000..1ff7212b
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt
@@ -0,0 +1,36 @@
+package com.github.damontecres.wholphin.data.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface SeerrPluginLogin {
+ @SerialName("None")
+ @Serializable
+ data object None : SeerrPluginLogin
+
+ @SerialName("ApiKey")
+ @Serializable
+ data class ApiKey(
+ val apiKey: String,
+ ) : SeerrPluginLogin
+
+ @SerialName("Local")
+ @Serializable
+ data class Local(
+ val local: SeerrPluginLocalLogin,
+ ) : SeerrPluginLogin
+}
+
+@Serializable
+data class SeerrPluginLocalLogin(
+ val username: String,
+ val password: String,
+)
+
+@Serializable
+data class SeerrPluginSettings(
+ val version: Int = 1,
+ val serverUrl: String? = null,
+ val login: SeerrPluginLogin? = null,
+)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt
new file mode 100644
index 00000000..5dd313d6
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt
@@ -0,0 +1,199 @@
+@file:UseSerializers(UUIDSerializer::class)
+
+package com.github.damontecres.wholphin.data.model
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import com.github.damontecres.wholphin.services.isSigns
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import org.jellyfin.sdk.model.api.MediaStream
+import org.jellyfin.sdk.model.api.MediaStreamType
+import org.jellyfin.sdk.model.serializer.UUIDSerializer
+import java.util.UUID
+
+/**
+ * Stores the user's chosen track for a media item. This is by type, ie Audio vs Subtitle tracks
+ *
+ * Additional information about the chosen track is stored to compare against tracks in other media
+ */
+@Entity(
+ foreignKeys = [
+ ForeignKey(
+ entity = JellyfinUser::class,
+ parentColumns = arrayOf("rowId"),
+ childColumns = arrayOf("userId"),
+ onDelete = ForeignKey.CASCADE,
+ onUpdate = ForeignKey.CASCADE,
+ ),
+ ],
+ primaryKeys = ["userId", "parentId", "type"],
+)
+@Serializable
+data class SeriesTrackChoice(
+ val userId: Int,
+ /**
+ * Season or Series
+ */
+ val parentId: UUID,
+ /**
+ * Type of track, ie audio or subtitle
+ */
+ val type: SeriesTrackChoiceType,
+ /**
+ * Origin of the [parentId]
+ */
+ val parentType: TrackChoiceParentType = TrackChoiceParentType.UNKNOWN,
+ val itemId: UUID? = null,
+ val language: String? = null,
+ val activation: ActivationFlag = ActivationFlag.ACTIVATED,
+ /**
+ * Flags for the track such as external or forced
+ *
+ * @see [TrackFlag]
+ */
+ val trackFlags: Int = 0,
+ val codec: String? = null,
+ /**
+ * The track index based on its position within all streams of the same type
+ *
+ * @see [RelativeMediaStream]
+ */
+ val relativeTrackIndex: Int? = null,
+ val title: String? = null,
+ val audioChannels: Int? = null,
+) {
+ constructor(
+ userId: Int,
+ itemId: UUID,
+ parentId: UUID,
+ type: SeriesTrackChoiceType,
+ parentType: TrackChoiceParentType,
+ stream: RelativeMediaStream?,
+ activation: ActivationFlag,
+ ) : this(
+ userId = userId,
+ parentId = parentId,
+ parentType = parentType,
+ type = type,
+ itemId = itemId,
+ language = stream?.language,
+ activation = activation,
+ trackFlags = stream?.let { TrackFlag.calculateTrackFlags(stream.stream) } ?: 0,
+ codec = stream?.stream?.codec,
+ relativeTrackIndex = stream?.relativeIndex,
+ title = stream?.stream?.title,
+ audioChannels = stream?.stream?.channels,
+ )
+
+ companion object {
+ /**
+ * Convert the old [PlaybackLanguageChoice] into [SeriesTrackChoice]s
+ */
+ fun from(plc: PlaybackLanguageChoice): List =
+ buildList {
+ if (plc.subtitleLanguage != null) {
+ add(
+ SeriesTrackChoice(
+ userId = plc.userId,
+ parentId = plc.seriesId,
+ parentType = TrackChoiceParentType.DEPRECATED,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ itemId = plc.itemId,
+ language = plc.subtitleLanguage,
+ activation = if (plc.subtitlesDisabled == true) ActivationFlag.DISABLED else ActivationFlag.ACTIVATED,
+ trackFlags = 0,
+ codec = null,
+ relativeTrackIndex = null,
+ title = null,
+ audioChannels = null,
+ ),
+ )
+ }
+ if (plc.audioLanguage != null) {
+ add(
+ SeriesTrackChoice(
+ userId = plc.userId,
+ parentId = plc.seriesId,
+ parentType = TrackChoiceParentType.DEPRECATED,
+ type = SeriesTrackChoiceType.AUDIO,
+ itemId = plc.itemId,
+ language = plc.audioLanguage,
+ activation = ActivationFlag.ACTIVATED,
+ trackFlags = 0,
+ codec = null,
+ relativeTrackIndex = null,
+ title = null,
+ audioChannels = null,
+ ),
+ )
+ }
+ }
+ }
+}
+
+enum class SeriesTrackChoiceType(
+ val mediaStreamType: MediaStreamType,
+) {
+ AUDIO(MediaStreamType.AUDIO),
+ SUBTITLE(MediaStreamType.SUBTITLE),
+}
+
+/**
+ * Flags for a track such as forced or defect
+ */
+enum class TrackFlag(
+ val flag: Int,
+ val hasFlag: (MediaStream) -> Boolean,
+) {
+ DEFAULT(flag = 1, hasFlag = { it.isDefault }),
+ FORCED(flag = 2, hasFlag = { it.isForced }),
+ SDH(flag = 4, hasFlag = { it.isHearingImpaired }),
+ EXTERNAL(flag = 8, hasFlag = { it.isExternal }),
+ SIGNS(flag = 16, hasFlag = { isSigns(it) }),
+ ;
+
+ fun within(flag: Int) = flag and this.flag == this.flag
+
+ companion object {
+ fun SeriesTrackChoice.has(flag: TrackFlag) = flag.within(trackFlags)
+
+ fun Iterable.calculateFlag() = fold(0) { flag, trackFlag -> flag or trackFlag.flag }
+
+ fun calculateTrackFlags(track: MediaStream): Int = TrackFlag.entries.filter { it.hasFlag.invoke(track) }.calculateFlag()
+ }
+}
+
+/**
+ * How was the track activated
+ */
+enum class ActivationFlag {
+ ACTIVATED,
+ DISABLED,
+ ONLY_FORCED,
+}
+
+/**
+ * Where the [SeriesTrackChoice.parentId] comes from
+ */
+enum class TrackChoiceParentType {
+ /**
+ * Parent ID is from the series
+ */
+ SERIES,
+
+ /**
+ * Parent ID is from the season
+ */
+ SEASON,
+
+ /**
+ * Parent ID is from the deprecated [PlaybackLanguageChoice]
+ */
+ DEPRECATED,
+
+ /**
+ * Parent ID origin is unknown
+ */
+ UNKNOWN,
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
index 188b7020..63761d99 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
@@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.services.UpdateChecker
+import com.github.damontecres.wholphin.ui.formatMaxBitrate
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
@@ -345,7 +346,7 @@ sealed interface AppPreference {
const val MEGA_BIT = 1024 * 1024L
const val DEFAULT_BITRATE = 100 * MEGA_BIT
- private val bitrateValues =
+ val bitrateValues =
listOf(
500 * 1024L,
750 * 1024L,
@@ -378,11 +379,7 @@ sealed interface AppPreference {
summarizer = { value ->
if (value != null) {
val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE
- if (v < MEGA_BIT) {
- "${v / 1024} kbps"
- } else {
- "${v / MEGA_BIT} Mbps"
- }
+ formatMaxBitrate(v)
} else {
null
}
@@ -456,15 +453,69 @@ sealed interface AppPreference {
summaryOff = R.string.disabled,
)
- val DirectPlayDoviProfile7 =
- AppSwitchPreference(
- title = R.string.force_dovi_profile_7,
- defaultValue = false,
- getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL },
+ val Hdr10Override =
+ AppChoicePreference(
+ title = R.string.hdr10_override,
+ defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
+ getter = { it.playbackPreferences.overrides.hdr10Override },
setter = { prefs, value ->
- prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value }
+ prefs.updatePlaybackOverrides { hdr10Override = value }
},
- summary = R.string.force_dovi_profile_7_summary,
+ displayValues = R.array.hdr_override_modes,
+ indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
+ valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
+ )
+
+ val Hdr10PlusOverride =
+ AppChoicePreference(
+ title = R.string.hdr10_plus_override,
+ defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
+ getter = { it.playbackPreferences.overrides.hdr10PlusOverride },
+ setter = { prefs, value ->
+ prefs.updatePlaybackOverrides { hdr10PlusOverride = value }
+ },
+ displayValues = R.array.hdr_override_modes,
+ indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
+ valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
+ )
+
+ val DoviProfile5Override =
+ AppChoicePreference(
+ title = R.string.dovi_profile_5,
+ defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
+ getter = { it.playbackPreferences.overrides.doviProfile5Override },
+ setter = { prefs, value ->
+ prefs.updatePlaybackOverrides { doviProfile5Override = value }
+ },
+ displayValues = R.array.hdr_override_modes,
+ indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
+ valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
+ )
+
+ val DoviProfile7Override =
+ AppChoicePreference(
+ title = R.string.dovi_profile_7,
+ defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
+ getter = { it.playbackPreferences.overrides.doviProfile7Override },
+ setter = { prefs, value ->
+ prefs.updatePlaybackOverrides { doviProfile7Override = value }
+ },
+ displayValues = R.array.hdr_override_modes,
+ indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
+ valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
+ )
+
+ val DoviProfile8Override =
+ AppChoicePreference(
+ title = R.string.dovi_profile_8,
+ defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO,
+ getter = { it.playbackPreferences.overrides.doviProfile8Override },
+ setter = { prefs, value ->
+ prefs.updatePlaybackOverrides { doviProfile8Override = value }
+ },
+ displayValues = R.array.hdr_override_modes,
+ indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO },
+ valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 },
)
val DecodeAv1 =
@@ -919,6 +970,12 @@ sealed interface AppPreference {
destination = Destination.Settings(PreferenceScreenOption.MPV),
)
+ val HdrFormatSettings =
+ AppDestinationPreference(
+ title = R.string.hdr_formats,
+ destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS),
+ )
+
val MpvHardwareDecoding =
AppSwitchPreference(
title = R.string.mpv_hardware_decoding,
@@ -1183,8 +1240,17 @@ private val ExoPlayerSettings =
AppPreference.Ac3Supported,
AppPreference.AssSubtitleMode,
AppPreference.DirectPlayPgs,
- AppPreference.DirectPlayDoviProfile7,
AppPreference.DecodeAv1,
+ AppPreference.HdrFormatSettings,
+ )
+
+private val HdrFormatSettings =
+ listOf(
+ AppPreference.Hdr10Override,
+ AppPreference.Hdr10PlusOverride,
+ AppPreference.DoviProfile5Override,
+ AppPreference.DoviProfile7Override,
+ AppPreference.DoviProfile8Override,
)
val ExoPlayerPreferences =
@@ -1195,6 +1261,14 @@ val ExoPlayerPreferences =
),
)
+val HdrFormatPreferences =
+ listOf(
+ PreferenceGroup(
+ title = R.string.hdr_formats,
+ preferences = HdrFormatSettings,
+ ),
+ )
+
private val MpvSettings =
listOf(
AppPreference.MpvHardwareDecoding,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
index cb47ca94..85f8aade 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
@@ -186,6 +186,8 @@ class AppPreferencesSerializer
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
preferDolbyVisionOverHdr10Plus =
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue
+ convertDolbyVisionProfile7 =
+ ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue
}.build()
}.build()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt
index 0647a900..46c75636 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt
@@ -48,6 +48,17 @@ object ExperimentalPreference {
summary = R.string.prefer_dovi_over_hdr10_plus_summary,
)
+ val ConvertDolbyVisionProfile7 =
+ AppSwitchPreference(
+ title = R.string.convert_dovi_profile_7,
+ defaultValue = false,
+ getter = { it.experimentalPreferences.convertDolbyVisionProfile7 },
+ setter = { prefs, value ->
+ prefs.updateExperimentalPreferences { convertDolbyVisionProfile7 = value }
+ },
+ summary = R.string.convert_dovi_profile_7_summary,
+ )
+
val PreferAc3ForSurround =
AppSwitchPreference(
title = R.string.prefer_ac3_for_surround,
@@ -93,6 +104,7 @@ val experimentalPreferences =
listOf(
ExperimentalPreference.VideoTunneling,
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus,
+ ExperimentalPreference.ConvertDolbyVisionProfile7,
ExperimentalPreference.PreferAc3ForSurround,
ExperimentalPreference.DisableAudioOffload,
),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt
new file mode 100644
index 00000000..df0b6823
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt
@@ -0,0 +1,56 @@
+package com.github.damontecres.wholphin.preferences
+
+import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType
+
+// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/preference/constant/HdrFormat.kt
+
+/**
+ * HDR formats that the user can override in the device profile.
+ */
+enum class HdrFormat(
+ val getter: (AppPreferences) -> HdrOverrideMode,
+ val rangeTypeNames: (jellyfinTenEleven: Boolean) -> Set,
+) {
+ HDR10(
+ getter = { it.playbackPreferences.overrides.hdr10Override },
+ rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10.serialName) },
+ ),
+ HDR10_PLUS(
+ getter = { it.playbackPreferences.overrides.hdr10PlusOverride },
+ rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10_PLUS.serialName) },
+ ),
+ DOVI_PROFILE_5(
+ getter = { it.playbackPreferences.overrides.doviProfile5Override },
+ rangeTypeNames = { setOf(WholphinVideoRangeType.DOVI.serialName) },
+ ),
+ DOVI_PROFILE_7(
+ getter = { it.playbackPreferences.overrides.doviProfile7Override },
+ rangeTypeNames = { jellyfinTenEleven ->
+ if (jellyfinTenEleven) {
+ setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName)
+ } else {
+ emptySet()
+ }
+ },
+ ),
+ DOVI_PROFILE_8(
+ getter = { it.playbackPreferences.overrides.doviProfile8Override },
+ rangeTypeNames = { jellyfinTenEleven ->
+ buildSet {
+ add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName)
+ if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName)
+ }
+ },
+ ),
+}
+
+/**
+ * Collects the video range type names for every [HdrFormat] whose preference is currently set to [mode]
+ */
+fun AppPreferences.getHdrRangeTypesFor(
+ mode: HdrOverrideMode,
+ jellyfinTenEleven: Boolean,
+): Set =
+ HdrFormat.entries
+ .filter { it.getter(this) == mode }
+ .flatMapTo(mutableSetOf()) { it.rangeTypeNames(jellyfinTenEleven) }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
index 4910110a..17312378 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
@@ -6,14 +6,20 @@ import android.os.Build
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager
+import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
+import com.github.damontecres.wholphin.data.MostRecentServerProvider
+import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
+import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.RememberedTab
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
+import com.github.damontecres.wholphin.preferences.HdrOverrideMode
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
@@ -36,6 +42,7 @@ import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
@@ -56,6 +63,9 @@ class AppUpgradeHandler
private val seerrServerDao: SeerrServerDao,
private val serverDao: JellyfinServerDao,
private val rememberedTabDao: RememberedTabDao,
+ private val mostRecentServerProvider: MostRecentServerProvider,
+ private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
+ private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
) {
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
@@ -442,5 +452,59 @@ class AppUpgradeHandler
}
}
}
+
+ if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) {
+ try {
+ val prefs = appPreferences.data.firstOrNull()
+ val serverId = prefs?.currentServerId?.toUUIDOrNull()
+ val userId = prefs?.currentUserId?.toUUIDOrNull()
+ if (serverId != null && userId != null) {
+ val server = serverDao.getServer(serverId)
+ val user = server?.users?.firstOrNull { it.id == userId }
+ if (server != null && user != null) {
+ Timber.i("Migrating most recent server")
+ mostRecentServerProvider.save(CurrentUser(server.server, user))
+ }
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error during migration for most recent server")
+ }
+ appPreferences.updateData {
+ it.update {
+ currentServerId = ""
+ currentUserId = ""
+ }
+ }
+ }
+
+ if (previous.isEqualOrBefore(Version.fromString("1.0.10"))) {
+ // directPlayDolbyVisionEL was replaced by doviProfile7Override; carry the setting forward
+ appPreferences.updateData {
+ if (it.playbackPreferences.overrides.directPlayDolbyVisionEL) {
+ it.updatePlaybackOverrides {
+ doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE
+ directPlayDolbyVisionEL = false
+ }
+ } else {
+ it
+ }
+ }
+ }
+
+ if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) {
+ val count = seriesTrackChoiceDao.count()
+ if (count == 0) {
+ var offset = 0
+ while (true) {
+ val plcs = playbackLanguageChoiceDao.getAll(100, offset)
+ if (plcs.isEmpty()) break
+ offset += plcs.size
+
+ val stcs = plcs.flatMap(SeriesTrackChoice::from)
+ Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size)
+ seriesTrackChoiceDao.save(stcs)
+ }
+ }
+ }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
index b9b15f97..13ef5f70 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt
@@ -4,8 +4,12 @@ import android.content.Context
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
+import com.github.damontecres.wholphin.preferences.HdrFormat
+import com.github.damontecres.wholphin.preferences.HdrOverrideMode
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
import com.github.damontecres.wholphin.preferences.enabled
+import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
+import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
@@ -61,9 +65,22 @@ class DeviceProfileService
downMixAudio = newConfig.overrides.downmixStereo,
assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE,
pgsDirectPlay = newConfig.overrides.directPlayPgs,
- dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL,
decodeAv1 = prefs.overrides.decodeAv1,
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
+ forceEnabledHdr =
+ buildSet {
+ appPrefs
+ .getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven)
+ .let(::addAll)
+ // A profile 7 track the player converts to 8.1 is one the server need not transcode
+ if (DolbyVisionProfile7Conversion.isWanted(newConfig.experimental, mediaCodecCapabilitiesTest)) {
+ HdrFormat.DOVI_PROFILE_7
+ .rangeTypeNames(newConfig.jellyfinTenEleven)
+ .let(::addAll)
+ }
+ },
+ forceDisabledHdr =
+ appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven),
jellyfinTenEleven = newConfig.jellyfinTenEleven,
maxResolution = newConfig.overrides.maxResolution,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt
new file mode 100644
index 00000000..fe462b98
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt
@@ -0,0 +1,765 @@
+package com.github.damontecres.wholphin.services
+
+import androidx.core.net.toUri
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.HomeRowConfig
+import com.github.damontecres.wholphin.data.model.ServerUserConfig
+import com.github.damontecres.wholphin.data.model.createGenreDestination
+import com.github.damontecres.wholphin.data.model.createStudioDestination
+import com.github.damontecres.wholphin.preferences.HomePagePreferences
+import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
+import com.github.damontecres.wholphin.ui.HomeItemFields
+import com.github.damontecres.wholphin.ui.ProgramItemFields
+import com.github.damontecres.wholphin.ui.components.getGenreImageMap
+import com.github.damontecres.wholphin.ui.formatTypeName
+import com.github.damontecres.wholphin.ui.main.settings.Library
+import com.github.damontecres.wholphin.ui.playback.getTypeFor
+import com.github.damontecres.wholphin.ui.toBaseItems
+import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
+import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
+import com.github.damontecres.wholphin.ui.util.ResStringProvider
+import com.github.damontecres.wholphin.ui.util.StringStringProvider
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.GetArtistsHandler
+import com.github.damontecres.wholphin.util.GetGenresRequestHandler
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
+import com.github.damontecres.wholphin.util.GetPersonsHandler
+import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
+import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
+import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
+import com.github.damontecres.wholphin.util.HomeRowLoadingState
+import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.decodeFromStream
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.exception.InvalidStatusException
+import org.jellyfin.sdk.api.client.extensions.liveTvApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.model.DateTime
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.CollectionType
+import org.jellyfin.sdk.model.api.GetProgramsDto
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.ItemFields
+import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.SortOrder
+import org.jellyfin.sdk.model.api.request.GetArtistsRequest
+import org.jellyfin.sdk.model.api.request.GetGenresRequest
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
+import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
+import org.jellyfin.sdk.model.api.request.GetPersonsRequest
+import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
+import org.jellyfin.sdk.model.api.request.GetStudiosRequest
+import timber.log.Timber
+import java.time.LocalDateTime
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@OptIn(ExperimentalSerializationApi::class)
+@Singleton
+class HomeDataService
+ @Inject
+ constructor(
+ private val api: ApiClient,
+ @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
+ private val serverRepository: ServerRepository,
+ private val latestNextUpService: LatestNextUpService,
+ private val imageUrlService: ImageUrlService,
+ private val suggestionService: SuggestionService,
+ ) {
+ /**
+ * Fetch the data from the server for a given [HomeRowConfig]
+ */
+ suspend fun fetchDataForRow(
+ row: HomeRowConfig,
+ scope: CoroutineScope,
+ prefs: HomePagePreferences,
+ userDto: ServerUserConfig,
+ libraries: List,
+ limit: Int = prefs.maxItemsPerRow,
+ isRefresh: Boolean,
+ usePaging: Boolean = false,
+ ): HomeRowLoadingState =
+ when (row) {
+ is HomeRowConfig.ContinueWatching -> {
+ val resume =
+ latestNextUpService.getResume(
+ userDto.id,
+ limit,
+ true,
+ row.viewOptions.useSeries,
+ )
+
+ Success(
+ title = ResStringProvider(R.string.continue_watching),
+ items = resume,
+ viewOptions = row.viewOptions,
+ rowType = row,
+ showViewMore = resume.size >= limit,
+ )
+ }
+
+ is HomeRowConfig.NextUp -> {
+ val nextUp =
+ latestNextUpService.getNextUp(
+ userDto.id,
+ limit,
+ prefs.enableRewatchingNextUp,
+ false,
+ prefs.maxDaysNextUp,
+ row.viewOptions.useSeries,
+ )
+
+ Success(
+ title = ResStringProvider(R.string.next_up),
+ items = nextUp,
+ viewOptions = row.viewOptions,
+ rowType = row,
+ showViewMore = nextUp.size >= limit,
+ )
+ }
+
+ is HomeRowConfig.ContinueWatchingCombined -> {
+ val resume =
+ latestNextUpService.getResume(
+ userDto.id,
+ limit,
+ true,
+ row.viewOptions.useSeries,
+ )
+ val nextUp =
+ latestNextUpService.getNextUp(
+ userDto.id,
+ limit,
+ prefs.enableRewatchingNextUp,
+ false,
+ prefs.maxDaysNextUp,
+ row.viewOptions.useSeries,
+ )
+ val combined = latestNextUpService.buildCombined(resume, nextUp)
+
+ Success(
+ title = ResStringProvider(R.string.continue_watching),
+ items = combined.take(limit),
+ viewOptions = row.viewOptions,
+ rowType = row,
+ showViewMore = combined.size >= limit,
+ )
+ }
+
+ is HomeRowConfig.Genres -> {
+ val request =
+ GetGenresRequest(
+ parentId = row.parentId,
+ userId = userDto.id,
+ limit = limit,
+ )
+ val items =
+ GetGenresRequestHandler
+ .execute(api, request)
+ .content.items
+ val genreIds = items.map { it.id }
+ val genreImages =
+ getGenreImageMap(
+ api = api,
+ userId = serverRepository.currentUser?.id,
+ scope = scope,
+ imageUrlService = imageUrlService,
+ genres = genreIds,
+ parentId = row.parentId,
+ includeItemTypes = null,
+ cardWidthPx = null,
+ useCache = isRefresh,
+ )
+ val library =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+
+ val title =
+ library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
+ ?: ResStringProvider(R.string.genres)
+ val genres =
+ items.map {
+ BaseItem(
+ it,
+ false,
+ genreImages[it.id],
+ createGenreDestination(
+ genreId = it.id,
+ genreName = it.name ?: "",
+ parentId = row.parentId,
+ parentName = library?.name,
+ includeItemTypes = library?.includeItemTypes,
+ collectionType =
+ library?.collectionType
+ ?: CollectionType.UNKNOWN,
+ ),
+ )
+ }
+
+ Success(
+ title,
+ genres,
+ viewOptions = row.viewOptions,
+ rowType = row,
+ showViewMore = genres.size >= limit,
+ )
+ }
+
+ is HomeRowConfig.Studios -> {
+ val request =
+ GetStudiosRequest(
+ parentId = row.parentId,
+ userId = userDto.id,
+ limit = limit,
+ includeItemTypes = listOf(BaseItemKind.SERIES),
+ )
+ val items =
+ GetStudiosRequestHandler
+ .execute(api, request)
+ .content.items
+ val library =
+ libraries
+ .firstOrNull { it.itemId == row.parentId }
+ val title =
+ library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
+ ?: ResStringProvider(R.string.studios)
+ val studios =
+ items.map {
+ val imageUrl =
+ imageUrlService.getItemImageUrl(
+ itemId = it.id,
+ imageType = ImageType.THUMB,
+ tag = it.imageTags?.get(ImageType.THUMB),
+ )
+ BaseItem(
+ it,
+ false,
+ imageUrl,
+ createStudioDestination(
+ studioId = it.id,
+ name = it.name ?: "",
+ parentId = row.parentId,
+ parentName = library?.name,
+ includeItemTypes = library?.includeItemTypes,
+ ),
+ )
+ }
+
+ Success(
+ title,
+ studios,
+ rowType = row,
+ viewOptions = row.viewOptions,
+ showViewMore = studios.size >= limit,
+ )
+ }
+
+ is HomeRowConfig.RecentlyAdded -> {
+ val library = libraries.firstOrNull { it.itemId == row.parentId }
+ val title = getRecentlyAddedTitle(library?.name)
+ val request =
+ GetLatestMediaRequest(
+ fields = library.itemFields,
+ imageTypeLimit = 1,
+ parentId = row.parentId,
+ groupItems = true,
+ limit = limit,
+ isPlayed = null, // Server will handle user's preference
+ )
+ val latest =
+ api.userLibraryApi
+ .getLatestMedia(request)
+ .content
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ .let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ latest
+ }
+
+ is HomeRowConfig.RecentlyReleased -> {
+ val library = libraries.firstOrNull { it.itemId == row.parentId }
+ val title =
+ library?.name?.let {
+ ResArgStringProvider(R.string.recently_released_in, it)
+ } ?: ResStringProvider(R.string.recently_released)
+ val request =
+ GetItemsRequest(
+ parentId = row.parentId,
+ limit = limit,
+ sortBy =
+ listOf(
+ ItemSortBy.PREMIERE_DATE,
+ ItemSortBy.SERIES_SORT_NAME,
+ ItemSortBy.AIRED_EPISODE_ORDER,
+ ),
+ sortOrder =
+ listOf(
+ SortOrder.DESCENDING,
+ SortOrder.ASCENDING,
+ SortOrder.DESCENDING,
+ ),
+ fields = library.itemFields,
+ recursive = true,
+ maxPremiereDate = LocalDateTime.now(),
+ isUnaired = false,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.ByParent -> {
+ val library = libraries.firstOrNull { it.itemId == row.parentId }
+ val request =
+ GetItemsRequest(
+ userId = userDto.id,
+ parentId = row.parentId,
+ recursive = row.recursive,
+ sortBy =
+ row.sort?.let {
+ buildList {
+ if (it.sort == ItemSortBy.RANDOM) {
+ add(ItemSortBy.SORT_NAME)
+ add(ItemSortBy.RANDOM)
+ } else {
+ add(it.sort)
+ if (it.sort != ItemSortBy.SORT_NAME) {
+ add(ItemSortBy.SORT_NAME)
+ }
+ }
+ }
+ },
+ sortOrder =
+ row.sort?.let {
+ buildList {
+ if (it.sort == ItemSortBy.RANDOM) {
+ add(SortOrder.ASCENDING)
+ add(it.direction)
+ } else {
+ add(it.direction)
+ if (it.sort != ItemSortBy.SORT_NAME) {
+ add(SortOrder.ASCENDING)
+ }
+ }
+ }
+ },
+ limit = limit,
+ fields = library.itemFields,
+ )
+
+ // Not using getItemName because we want to throw the 404
+ val title =
+ api.userLibraryApi
+ .getItem(
+ userId = serverRepository.currentUser?.id,
+ itemId = row.parentId,
+ ).content.name
+ ?.let { StringStringProvider(it) }
+ ?: ResStringProvider(R.string.collection)
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.GetItems -> {
+ val request =
+ row.filter.asGetItemsRequest().copy(
+ userId = userDto.id,
+ limit = limit,
+ fields = HomeItemFieldsBoxSets,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetItemsRequestHandler
+ .execute(api, request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ StringStringProvider(row.name),
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.Favorite -> {
+ val title =
+ ResProviderStringProvider(
+ R.string.favorite_items_title,
+ ResStringProvider(formatTypeName(row.kind)),
+ )
+ val resultList =
+ when (row.kind) {
+ BaseItemKind.PERSON -> {
+ val request =
+ GetPersonsRequest(
+ userId = userDto.id,
+ limit = limit,
+ fields = HomeItemFields,
+ isFavorite = true,
+ enableImages = true,
+ enableImageTypes = listOf(ImageType.PRIMARY),
+ )
+
+ GetPersonsHandler.execute(api, request).toBaseItems()
+ }
+
+ BaseItemKind.MUSIC_ARTIST -> {
+ val request =
+ GetArtistsRequest(
+ userId = userDto.id,
+ limit = limit,
+ fields = HomeItemFields,
+ isFavorite = true,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetArtistsHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetArtistsHandler
+ .execute(api, request)
+ .toBaseItems(row.viewOptions.useSeries)
+ }
+ }
+
+ else -> {
+ val fields =
+ if (row.kind == BaseItemKind.BOX_SET) {
+ HomeItemFieldsBoxSets
+ } else {
+ HomeItemFields
+ }
+ val request =
+ GetItemsRequest(
+ userId = userDto.id,
+ recursive = true,
+ limit = limit,
+ fields = fields,
+ includeItemTypes = listOf(row.kind),
+ isFavorite = true,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ GetItemsRequestHandler
+ .execute(api, request)
+ .toBaseItems(row.viewOptions.useSeries)
+ }
+ }
+ }
+ resultList.let {
+ Success(
+ title,
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.Recordings -> {
+ val request =
+ GetRecordingsRequest(
+ userId = userDto.id,
+ isInProgress = true,
+ fields = HomeItemFields,
+ limit = limit,
+ enableImages = true,
+ enableUserData = true,
+ enableTotalRecordCount = false,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetRecordingsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ api.liveTvApi
+ .getRecordings(request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ ResStringProvider(R.string.active_recordings),
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.TvPrograms -> {
+ val request =
+ GetProgramsDto(
+ userId = userDto.id,
+ fields = ProgramItemFields,
+ limit = limit,
+ enableUserData = true,
+ enableImages = true,
+ enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
+ imageTypeLimit = 1,
+ isAiring = true,
+ minEndDate = DateTime.now().plusMinutes(1),
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetProgramsDtoHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ api.liveTvApi
+ .getPrograms(request)
+ .content.items
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ }.let {
+ Success(
+ ResStringProvider(R.string.watch_live),
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.TvChannels -> {
+ val request =
+ GetLiveTvChannelsRequest(
+ userId = userDto.id,
+ fields = HomeItemFields,
+ limit = limit,
+ enableImages = true,
+ )
+ if (usePaging) {
+ ApiRequestPager(
+ api,
+ request,
+ GetLiveTvChannelsRequestHandler,
+ scope,
+ useSeriesForPrimary = row.viewOptions.useSeries,
+ ).init()
+ } else {
+ api.liveTvApi
+ .getLiveTvChannels(request)
+ .toBaseItems(api, row.viewOptions.useSeries)
+ }.let {
+ Success(
+ ResStringProvider(R.string.channels),
+ it,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = it.size >= limit,
+ )
+ }
+ }
+
+ is HomeRowConfig.Suggestions -> {
+ val library =
+ api.userLibraryApi
+ .getItem(itemId = row.parentId)
+ .content
+ val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
+ val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
+ if (itemKind != null) {
+ val suggestions =
+ suggestionService
+ .getSuggestionsFlow(row.parentId, itemKind)
+ .firstOrNull()
+ when (suggestions) {
+ SuggestionsResource.Empty -> {
+ Success(
+ title,
+ listOf(),
+ row.viewOptions,
+ rowType = row,
+ )
+ }
+
+ is SuggestionsResource.Success -> {
+ Success(
+ title,
+ suggestions.items,
+ row.viewOptions,
+ rowType = row,
+ showViewMore = suggestions.items.size >= limit,
+ )
+ }
+
+ SuggestionsResource.Loading,
+ null,
+ -> {
+ HomeRowLoadingState.Loading(title)
+ }
+ }
+ } else {
+ HomeRowLoadingState.Error(
+ title = title,
+ message = "Unsupported type ${library.collectionType}",
+ )
+ }
+ }
+
+ is HomeRowConfig.CustomEndpoint -> {
+ val title = StringStringProvider(row.title)
+ try {
+ val items =
+ fetchCustomEndpointItems(row)
+ .map { BaseItem(it, row.viewOptions.useSeries) }
+ Success(title, items, row.viewOptions, rowType = row)
+ } catch (ex: Exception) {
+ Timber.w(ex, "Custom endpoint %s failed", row.endpoint)
+ HomeRowLoadingState.Error(title, exception = ex)
+ }
+ }
+ }
+
+ private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List {
+ val base =
+ api.baseUrl?.toHttpUrlOrNull()
+ ?: throw IllegalStateException("Jellyfin baseUrl not set")
+ val endpointUri = row.endpoint.toUri()
+ if (!endpointUri.isRelative) {
+ throw IllegalArgumentException("Custom endpoint must be an path relative to Jellyfin baseUrl: ${row.endpoint}")
+ }
+ val params =
+ buildMap {
+ serverRepository.currentUser
+ ?.id
+ ?.toString()
+ ?.let { put("userId", it) }
+ row.query?.forEach { put(it.key, it.value) }
+ }
+ val resolved =
+ base
+ .resolve(row.endpoint)
+ ?.newBuilder()
+ ?.apply {
+ params.forEach { (k, v) -> addQueryParameter(k, v) }
+ }?.build()
+ ?: throw IllegalStateException("Could not resolve endpoint: ${row.endpoint}")
+ val request =
+ Request
+ .Builder()
+ .url(resolved)
+ .get()
+ .apply {
+ row.headers?.forEach { header(it.key, it.value) }
+ }.build()
+ val response =
+ authOkHttpClient
+ .newCall(request)
+ .execute()
+ return response.use {
+ if (!it.isSuccessful) {
+ throw InvalidStatusException(it.code, null)
+ }
+ val queryResult =
+ HomeSettingsService.jsonParser.decodeFromStream(it.body.byteStream())
+ Timber.v("Got %s items from custom endpoint", queryResult.items.size)
+ queryResult.items
+ }
+ }
+ }
+
+private val Library?.itemFields: List
+ get() =
+ if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
+ // Get child count to show in the header
+ HomeItemFieldsBoxSets
+ } else {
+ HomeItemFields
+ }
+
+private val Library.includeItemTypes: List?
+ get() =
+ collectionType.let { collectionType ->
+ getTypeFor(collectionType)?.let { type -> listOf(type) }
+ }
+
+private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
index c070b37e..1c05640e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt
@@ -2,48 +2,27 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.StringRes
+import androidx.annotation.VisibleForTesting
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository
-import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
+import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
-import com.github.damontecres.wholphin.data.model.ServerUserConfig
-import com.github.damontecres.wholphin.data.model.createGenreDestination
-import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
-import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
-import com.github.damontecres.wholphin.ui.HomeItemFields
-import com.github.damontecres.wholphin.ui.ProgramItemFields
-import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.formatTypeName
-import com.github.damontecres.wholphin.ui.main.settings.Library
-import com.github.damontecres.wholphin.ui.playback.getTypeFor
-import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider
-import com.github.damontecres.wholphin.util.ApiRequestPager
-import com.github.damontecres.wholphin.util.GetArtistsHandler
-import com.github.damontecres.wholphin.util.GetGenresRequestHandler
-import com.github.damontecres.wholphin.util.GetItemsRequestHandler
-import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
-import com.github.damontecres.wholphin.util.GetPersonsHandler
-import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
-import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
-import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
-import com.github.damontecres.wholphin.util.HomeRowLoadingState
-import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
@@ -55,32 +34,15 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
-import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.UUID
-import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
-import org.jellyfin.sdk.model.api.GetProgramsDto
-import org.jellyfin.sdk.model.api.ImageType
-import org.jellyfin.sdk.model.api.ItemFields
-import org.jellyfin.sdk.model.api.ItemSortBy
-import org.jellyfin.sdk.model.api.SortOrder
-import org.jellyfin.sdk.model.api.UserDto
-import org.jellyfin.sdk.model.api.request.GetArtistsRequest
-import org.jellyfin.sdk.model.api.request.GetGenresRequest
-import org.jellyfin.sdk.model.api.request.GetItemsRequest
-import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
-import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
-import org.jellyfin.sdk.model.api.request.GetPersonsRequest
-import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
-import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber
import java.io.File
-import java.time.LocalDateTime
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.coroutines.cancellation.CancellationException
/**
* Handles getting home page settings and data
@@ -92,21 +54,11 @@ class HomeSettingsService
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
- private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService,
- private val latestNextUpService: LatestNextUpService,
- private val imageUrlService: ImageUrlService,
- private val suggestionService: SuggestionService,
private val displayPreferencesService: DisplayPreferencesService,
+ private val serverPluginApi: ServerPluginApi,
+ private val serverDao: JellyfinServerDao,
) {
- @OptIn(ExperimentalSerializationApi::class)
- val jsonParser =
- Json {
- isLenient = true
- ignoreUnknownKeys = true
- allowTrailingComma = true
- }
-
/**
* The current home page settings
*/
@@ -188,9 +140,8 @@ class HomeSettingsService
/**
* Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
- *
- * This is public only for testing
*/
+ @VisibleForTesting
fun decode(element: JsonElement): HomePageSettings {
val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
@@ -211,6 +162,42 @@ class HomeSettingsService
return HomePageSettings(rows, version)
}
+ private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
+ try {
+ block.invoke()
+ } catch (ex: Exception) {
+ Timber.w(ex, "Error loading settings")
+ null
+ }
+
+ suspend fun fetchSettingsFor(
+ userId: UUID,
+ source: HomePageSettingsSource,
+ ): HomePageResolvedSettings? {
+ val (resolvedSource, settings) =
+ when (source) {
+ HomePageSettingsSource.UNSET -> loadInOrder(userId)
+ HomePageSettingsSource.LOCAL -> source to loadFromLocal(userId)
+ HomePageSettingsSource.SERVER_PROFILE -> source to loadFromServer(userId)
+ HomePageSettingsSource.PLUGIN -> source to serverPluginApi.fetchHomePageSettings()
+ HomePageSettingsSource.DEFAULT -> source to createDefault(userId).asHomePageSettings()
+ HomePageSettingsSource.WEB_CONFIG -> source to parseFromWebConfig(userId)?.asHomePageSettings()
+ }
+ Timber.i(
+ "fetch home settings: resolvedSource=%s, userId=%s, found=%s",
+ resolvedSource,
+ userId,
+ settings != null,
+ )
+ return settings?.let {
+ val resolvedRows =
+ settings.rows.mapIndexed { index, config ->
+ resolve(index, config)
+ }
+ HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
+ }
+ }
+
/**
* Loads [HomePageSettings] into [currentSettings]
*
@@ -218,55 +205,70 @@ class HomeSettingsService
*
* Does not persist either the server nor default
*/
- suspend fun loadCurrentSettings(userId: UUID) {
- Timber.v("Getting setting for %s", userId)
- // User local then server/remote otherwise create a default
+ suspend fun loadCurrentSettings(user: JellyfinUser) {
+ currentSettings.update { HomePageResolvedSettings.EMPTY }
+
+ Timber.v("Getting setting for %s", user.id)
val settings =
try {
- val local = loadFromLocal(userId)
- Timber.v("Found local? %s", local != null)
- local
+ fetchSettingsFor(user.id, user.config.homeSettingsSource)
+ } catch (ex: CancellationException) {
+ throw ex
} catch (ex: Exception) {
- Timber.w(ex, "Error loading local settings")
- // TODO show toast?
- null
- } ?: try {
- val remote = loadFromServer(userId)
- Timber.v("Found remote? %s", remote != null)
- remote
- } catch (ex: Exception) {
- Timber.w(ex, "Error loading remote settings")
+ Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource)
null
}
- val resolvedSettings =
- if (settings != null) {
- Timber.v("Found settings")
- // Resolve
- val resolvedRows =
- settings.rows.mapIndexed { index, config ->
- resolve(index, config)
- }
- HomePageResolvedSettings(userId, resolvedRows)
- } else {
- createDefault(userId)
- }
-
+ Timber.v("Found settings: %s", settings != null)
+ val resolvedSettings = settings ?: createDefault(user.id)
currentSettings.update { resolvedSettings }
}
+ /**
+ * Tries to load settings from local->server->plugin
+ */
+ private suspend fun loadInOrder(userId: UUID): Pair {
+ Timber.v("loadInOrder: local")
+ var settings =
+ HomePageSettingsSource.LOCAL to
+ tryLoad {
+ loadFromLocal(userId)
+ }
+
+ if (settings.second == null) {
+ Timber.v("loadInOrder: server")
+ settings =
+ HomePageSettingsSource.SERVER_PROFILE to
+ tryLoad {
+ loadFromServer(userId)
+ }
+ }
+
+ if (settings.second == null && serverRepository.serverPluginInstalled.value) {
+ Timber.v("loadInOrder: plugin")
+ settings = HomePageSettingsSource.PLUGIN to
+ tryLoad {
+ serverPluginApi.fetchHomePageSettings()
+ }
+ }
+ return settings
+ }
+
/**
* Resolve the settings and set them to be the current settings
*/
suspend fun updateCurrent(
- userId: UUID,
+ source: HomePageSettingsSource,
settings: HomePageSettings,
) {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
- val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows)
- currentSettings.update { resolvedSettings }
+ currentSettings.update { it.copy(rows = resolvedRows) }
+ serverRepository.currentUser?.let { user ->
+ val toSave = user.updateConfig { it.copy(homeSettingsSource = source) }
+ serverDao.updateUser(toSave)
+ }
}
/**
@@ -316,7 +318,7 @@ class HomeSettingsService
),
)
val rowConfig = continueWatchingRow + includedIds
- return HomePageResolvedSettings(userId, rowConfig)
+ return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig)
}
/**
@@ -432,7 +434,7 @@ class HomeSettingsService
null
}
}.flatten()
- HomePageResolvedSettings(userId, rowConfigs)
+ HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs)
} else {
null
}
@@ -560,6 +562,14 @@ class HomeSettingsService
config,
)
}
+
+ is HomeRowConfig.CustomEndpoint -> {
+ HomeRowConfigDisplay(
+ id = id,
+ title = StringStringProvider(config.title),
+ config,
+ )
+ }
}
private suspend fun getItemName(
@@ -585,631 +595,16 @@ class HomeSettingsService
ResStringProvider(R.string.unknown)
}
- /**
- * Fetch the data from the server for a given [HomeRowConfig]
- */
- suspend fun fetchDataForRow(
- row: HomeRowConfig,
- scope: CoroutineScope,
- prefs: HomePagePreferences,
- userDto: ServerUserConfig,
- libraries: List,
- limit: Int = prefs.maxItemsPerRow,
- isRefresh: Boolean,
- usePaging: Boolean = false,
- ): HomeRowLoadingState =
- when (row) {
- is HomeRowConfig.ContinueWatching -> {
- val resume =
- latestNextUpService.getResume(
- userDto.id,
- limit,
- true,
- row.viewOptions.useSeries,
- )
-
- Success(
- title = ResStringProvider(R.string.continue_watching),
- items = resume,
- viewOptions = row.viewOptions,
- rowType = row,
- showViewMore = resume.size >= limit,
- )
- }
-
- is HomeRowConfig.NextUp -> {
- val nextUp =
- latestNextUpService.getNextUp(
- userDto.id,
- limit,
- prefs.enableRewatchingNextUp,
- false,
- prefs.maxDaysNextUp,
- row.viewOptions.useSeries,
- )
-
- Success(
- title = ResStringProvider(R.string.next_up),
- items = nextUp,
- viewOptions = row.viewOptions,
- rowType = row,
- showViewMore = nextUp.size >= limit,
- )
- }
-
- is HomeRowConfig.ContinueWatchingCombined -> {
- val resume =
- latestNextUpService.getResume(
- userDto.id,
- limit,
- true,
- row.viewOptions.useSeries,
- )
- val nextUp =
- latestNextUpService.getNextUp(
- userDto.id,
- limit,
- prefs.enableRewatchingNextUp,
- false,
- prefs.maxDaysNextUp,
- row.viewOptions.useSeries,
- )
- val combined = latestNextUpService.buildCombined(resume, nextUp)
-
- Success(
- title = ResStringProvider(R.string.continue_watching),
- items = combined.take(limit),
- viewOptions = row.viewOptions,
- rowType = row,
- showViewMore = combined.size >= limit,
- )
- }
-
- is HomeRowConfig.Genres -> {
- val request =
- GetGenresRequest(
- parentId = row.parentId,
- userId = userDto.id,
- limit = limit,
- )
- val items =
- GetGenresRequestHandler
- .execute(api, request)
- .content.items
- val genreIds = items.map { it.id }
- val genreImages =
- getGenreImageMap(
- api = api,
- userId = serverRepository.currentUser?.id,
- scope = scope,
- imageUrlService = imageUrlService,
- genres = genreIds,
- parentId = row.parentId,
- includeItemTypes = null,
- cardWidthPx = null,
- useCache = isRefresh,
- )
- val library =
- libraries
- .firstOrNull { it.itemId == row.parentId }
-
- val title =
- library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
- ?: ResStringProvider(R.string.genres)
- val genres =
- items.map {
- BaseItem(
- it,
- false,
- genreImages[it.id],
- createGenreDestination(
- genreId = it.id,
- genreName = it.name ?: "",
- parentId = row.parentId,
- parentName = library?.name,
- includeItemTypes =
- library?.collectionType?.let {
- getTypeFor(it)?.let {
- listOf(it)
- }
- },
- collectionType =
- library?.collectionType
- ?: CollectionType.UNKNOWN,
- ),
- )
- }
-
- Success(
- title,
- genres,
- viewOptions = row.viewOptions,
- rowType = row,
- showViewMore = genres.size >= limit,
- )
- }
-
- is HomeRowConfig.Studios -> {
- val request =
- GetStudiosRequest(
- parentId = row.parentId,
- userId = userDto.id,
- limit = limit,
- includeItemTypes = listOf(BaseItemKind.SERIES),
- )
- val items =
- GetStudiosRequestHandler
- .execute(api, request)
- .content.items
- val library =
- libraries
- .firstOrNull { it.itemId == row.parentId }
- val title =
- library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
- ?: ResStringProvider(R.string.studios)
- val studios =
- items.map {
- val imageUrl =
- imageUrlService.getItemImageUrl(
- itemId = it.id,
- imageType = ImageType.THUMB,
- )
- BaseItem(
- it,
- false,
- imageUrl,
- createStudioDestination(
- studioId = it.id,
- name = it.name ?: "",
- parentId = row.parentId,
- parentName = library?.name,
- includeItemTypes =
- library?.collectionType?.let {
- getTypeFor(it)?.let {
- listOf(it)
- }
- },
- ),
- )
- }
-
- Success(
- title,
- studios,
- viewOptions = row.viewOptions,
- showViewMore = studios.size >= limit,
- )
- }
-
- is HomeRowConfig.RecentlyAdded -> {
- val library = libraries.firstOrNull { it.itemId == row.parentId }
- val title = getRecentlyAddedTitle(library?.name)
- val request =
- GetLatestMediaRequest(
- fields = library.itemFields,
- imageTypeLimit = 1,
- parentId = row.parentId,
- groupItems = true,
- limit = limit,
- isPlayed = null, // Server will handle user's preference
- )
- val latest =
- api.userLibraryApi
- .getLatestMedia(request)
- .content
- .map { BaseItem(it, row.viewOptions.useSeries) }
- .let {
- Success(
- title,
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- latest
- }
-
- is HomeRowConfig.RecentlyReleased -> {
- val library = libraries.firstOrNull { it.itemId == row.parentId }
- val title =
- library?.name?.let {
- ResArgStringProvider(R.string.recently_released_in, it)
- } ?: ResStringProvider(R.string.recently_released)
- val request =
- GetItemsRequest(
- parentId = row.parentId,
- limit = limit,
- sortBy =
- listOf(
- ItemSortBy.PREMIERE_DATE,
- ItemSortBy.SERIES_SORT_NAME,
- ItemSortBy.AIRED_EPISODE_ORDER,
- ),
- sortOrder =
- listOf(
- SortOrder.DESCENDING,
- SortOrder.ASCENDING,
- SortOrder.DESCENDING,
- ),
- fields = library.itemFields,
- recursive = true,
- maxPremiereDate = LocalDateTime.now(),
- isUnaired = false,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetItemsRequestHandler
- .execute(api, request)
- .content.items
- .map { BaseItem.from(it, api, row.viewOptions.useSeries) }
- }.let {
- Success(
- title,
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.ByParent -> {
- val library = libraries.firstOrNull { it.itemId == row.parentId }
- val request =
- GetItemsRequest(
- userId = userDto.id,
- parentId = row.parentId,
- recursive = row.recursive,
- sortBy =
- row.sort?.let {
- buildList {
- if (it.sort == ItemSortBy.RANDOM) {
- add(ItemSortBy.SORT_NAME)
- add(ItemSortBy.RANDOM)
- } else {
- add(it.sort)
- if (it.sort != ItemSortBy.SORT_NAME) {
- add(ItemSortBy.SORT_NAME)
- }
- }
- }
- },
- sortOrder =
- row.sort?.let {
- buildList {
- if (it.sort == ItemSortBy.RANDOM) {
- add(SortOrder.ASCENDING)
- add(it.direction)
- } else {
- add(it.direction)
- if (it.sort != ItemSortBy.SORT_NAME) {
- add(SortOrder.ASCENDING)
- }
- }
- }
- },
- limit = limit,
- fields = library.itemFields,
- )
-
- // Not using getItemName because we want to throw the 404
- val title =
- api.userLibraryApi
- .getItem(
- userId = serverRepository.currentUser?.id,
- itemId = row.parentId,
- ).content.name
- ?.let { StringStringProvider(it) }
- ?: ResStringProvider(R.string.collection)
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetItemsRequestHandler
- .execute(api, request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- }.let {
- Success(
- title,
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.GetItems -> {
- val request =
- row.getItems.let {
- if (it.limit == null) {
- it.copy(
- userId = userDto.id,
- limit = limit,
- )
- } else {
- it.copy(
- userId = userDto.id,
- )
- }
- }
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetItemsRequestHandler
- .execute(api, request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- }.let {
- Success(
- StringStringProvider(row.name),
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.Favorite -> {
- val title =
- ResProviderStringProvider(
- R.string.favorite_items_title,
- ResStringProvider(formatTypeName(row.kind)),
- )
- val resultList =
- when (row.kind) {
- BaseItemKind.PERSON -> {
- val request =
- GetPersonsRequest(
- userId = userDto.id,
- limit = limit,
- fields = HomeItemFields,
- isFavorite = true,
- enableImages = true,
- enableImageTypes = listOf(ImageType.PRIMARY),
- )
-
- GetPersonsHandler.execute(api, request).toBaseItems()
- }
-
- BaseItemKind.MUSIC_ARTIST -> {
- val request =
- GetArtistsRequest(
- userId = userDto.id,
- limit = limit,
- fields = HomeItemFields,
- isFavorite = true,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetArtistsHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetArtistsHandler
- .execute(api, request)
- .toBaseItems(row.viewOptions.useSeries)
- }
- }
-
- else -> {
- val fields =
- if (row.kind == BaseItemKind.BOX_SET) {
- HomeItemFieldsBoxSets
- } else {
- HomeItemFields
- }
- val request =
- GetItemsRequest(
- userId = userDto.id,
- recursive = true,
- limit = limit,
- fields = fields,
- includeItemTypes = listOf(row.kind),
- isFavorite = true,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- GetItemsRequestHandler
- .execute(api, request)
- .toBaseItems(row.viewOptions.useSeries)
- }
- }
- }
- resultList.let {
- Success(
- title,
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.Recordings -> {
- val request =
- GetRecordingsRequest(
- userId = userDto.id,
- isInProgress = true,
- fields = HomeItemFields,
- limit = limit,
- enableImages = true,
- enableUserData = true,
- enableTotalRecordCount = false,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetRecordingsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- api.liveTvApi
- .getRecordings(request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- }.let {
- Success(
- ResStringProvider(R.string.active_recordings),
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.TvPrograms -> {
- val request =
- GetProgramsDto(
- userId = userDto.id,
- fields = ProgramItemFields,
- limit = limit,
- enableUserData = true,
- enableImages = true,
- enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
- imageTypeLimit = 1,
- isAiring = true,
- minEndDate = DateTime.now().plusMinutes(1),
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetProgramsDtoHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- api.liveTvApi
- .getPrograms(request)
- .content.items
- .map { BaseItem(it, row.viewOptions.useSeries) }
- }.let {
- Success(
- ResStringProvider(R.string.watch_live),
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.TvChannels -> {
- val request =
- GetLiveTvChannelsRequest(
- userId = userDto.id,
- fields = HomeItemFields,
- limit = limit,
- enableImages = true,
- )
- if (usePaging) {
- ApiRequestPager(
- api,
- request,
- GetLiveTvChannelsRequestHandler,
- scope,
- useSeriesForPrimary = row.viewOptions.useSeries,
- ).init()
- } else {
- api.liveTvApi
- .getLiveTvChannels(request)
- .toBaseItems(api, row.viewOptions.useSeries)
- }.let {
- Success(
- ResStringProvider(R.string.channels),
- it,
- row.viewOptions,
- rowType = row,
- showViewMore = it.size >= limit,
- )
- }
- }
-
- is HomeRowConfig.Suggestions -> {
- val library =
- api.userLibraryApi
- .getItem(itemId = row.parentId)
- .content
- val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
- val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
- if (itemKind != null) {
- val suggestions =
- suggestionService
- .getSuggestionsFlow(row.parentId, itemKind)
- .firstOrNull()
- when (suggestions) {
- SuggestionsResource.Empty -> {
- Success(
- title,
- listOf(),
- row.viewOptions,
- rowType = row,
- )
- }
-
- is SuggestionsResource.Success -> {
- Success(
- title,
- suggestions.items,
- row.viewOptions,
- rowType = row,
- showViewMore = suggestions.items.size >= limit,
- )
- }
-
- SuggestionsResource.Loading,
- null,
- -> {
- HomeRowLoadingState.Loading(title)
- }
- }
- } else {
- HomeRowLoadingState.Error(
- title = title,
- message = "Unsupported type ${library.collectionType}",
- )
- }
- }
- }
-
companion object {
const val CUSTOM_PREF_ID = "home_settings"
+
+ @OptIn(ExperimentalSerializationApi::class)
+ val jsonParser =
+ Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ allowTrailingComma = true
+ }
}
}
@@ -1229,10 +624,14 @@ data class HomeRowConfigDisplay(
*/
data class HomePageResolvedSettings(
val userId: UUID,
+ val source: HomePageSettingsSource,
val rows: List,
) {
+ fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
+
companion object {
- val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList())
+ val EMPTY =
+ HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList())
}
}
@@ -1266,17 +665,6 @@ fun getRecentlyAddedTitle(name: String?): StringProvider =
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
?: ResStringProvider(R.string.recently_added)
-private val Library?.itemFields: List
- get() =
- if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
- // Get child count to show in the header
- HomeItemFieldsBoxSets
- } else {
- HomeItemFields
- }
-
-private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)
-
fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions =
when (collectionType) {
CollectionType.MUSIC,
@@ -1315,3 +703,14 @@ fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOp
HomeRowViewOptions()
}
}
+
+enum class HomePageSettingsSource(
+ @param:StringRes val stringResId: Int,
+) {
+ UNSET(R.string.unknown),
+ LOCAL(R.string.home_settings_source_local),
+ SERVER_PROFILE(R.string.home_settings_source_server_profile),
+ PLUGIN(R.string.home_settings_source_plugin),
+ DEFAULT(R.string.home_settings_source_unset),
+ WEB_CONFIG(R.string.home_settings_source_web),
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
index 6b71d683..967629da 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
@@ -35,6 +35,11 @@ class ImageUrlService
parentBackdropId: UUID? = null,
fillWidth: Int? = null,
fillHeight: Int? = null,
+ seriesPrimaryTag: String? = null,
+ parentLogoId: UUID? = null,
+ parentLogoTag: String? = null,
+ parentThumbTag: String? = null,
+ parentBackdropTags: List = emptyList(),
): String? =
when (imageType) {
ImageType.LOGO -> {
@@ -44,6 +49,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = parentLogoTag.takeIf { parentLogoId == seriesId },
)
} else {
getItemImageUrl(
@@ -51,6 +57,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = imageTags[imageType],
)
}
}
@@ -63,6 +70,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId },
)
} else if (backdropTags.isNotEmpty()) {
getItemImageUrl(
@@ -70,6 +78,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = backdropTags.first(),
)
} else {
null
@@ -86,6 +95,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = parentThumbTag,
)
} else if (useSeriesForPrimary && parentBackdropId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
@@ -96,6 +106,7 @@ class ImageUrlService
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = parentBackdropTags.firstOrNull(),
)
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl(
@@ -103,6 +114,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = parentThumbTag,
)
} else if (useSeriesForPrimary &&
parentThumbId == null &&
@@ -115,6 +127,7 @@ class ImageUrlService
imageType = ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = imageTags[ImageType.PRIMARY],
)
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
// If no thumb, use backdrop if available
@@ -123,6 +136,7 @@ class ImageUrlService
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = backdropTags.first(),
)
} else {
getItemImageUrl(
@@ -130,6 +144,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = imageTags[imageType],
)
}
}
@@ -145,6 +160,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
)
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl(
@@ -152,6 +168,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
)
} else {
getItemImageUrl(
@@ -159,6 +176,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = imageTags[imageType],
)
}
}
@@ -169,6 +187,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
+ tag = imageTags[imageType],
)
}
}
@@ -193,6 +212,11 @@ class ImageUrlService
backdropTags = item.data.backdropImageTags.orEmpty(),
fillWidth = fillWidth,
fillHeight = fillHeight,
+ seriesPrimaryTag = item.data.seriesPrimaryImageTag,
+ parentLogoId = item.data.parentLogoItemId,
+ parentLogoTag = item.data.parentLogoImageTag,
+ parentThumbTag = item.data.parentThumbImageTag,
+ parentBackdropTags = item.data.parentBackdropImageTags.orEmpty(),
)
} else {
null
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt
index 2dd0cde5..4fda8260 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt
@@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent
+import androidx.annotation.VisibleForTesting
+import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
@@ -114,6 +116,7 @@ class IntentService
return IntentResult.Target(destinations, true)
}
+ @VisibleForTesting
internal suspend fun prepare(intent: Intent): IntentResult? {
val appPrefs = userPreferencesService.flow.first().appPreferences
@@ -121,39 +124,33 @@ class IntentService
val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull()
return if (userId != null && serverId != null) {
Timber.v("Intent switches user")
- val user = serverRepository.serverDao.getUser(serverId, userId)
- if (user != null && !user.isProtected) {
- serverRepository.restoreSession(serverId, userId)
- ?: IntentResult.Error("Error restoring user")
+ val user = serverRepository.tryChangeUser(serverId, userId)
+ if (user != null) {
null
} else {
- IntentResult.Error("Cannot switch to specified user")
+ IntentResult.Error("Error restoring user")
}
- } else {
- val profileProtected =
- serverRepository.current.value
- ?.user
- ?.isProtected == true
- if (appPrefs.signInAutomatically && !profileProtected) {
- Timber.v("No current user, so restoring last")
- val userId = appPrefs.currentUserId?.toUUIDOrNull()
- val serverId = appPrefs.currentServerId?.toUUIDOrNull()
+ } else if (appPrefs.signInAutomatically) {
+ Timber.v("No current user, so restoring last")
+ val result = serverRepository.restoreLastSession()
+ Timber.v("Restored result=%s", result)
+ when (result) {
+ RestoredSession.None,
+ is RestoredSession.ServerOnly,
+ -> {
+ IntentResult.Error("Could not auto sign-in, specify a server & user")
+ }
- if (userId != null && serverId != null) {
- val current =
- serverRepository.restoreSession(serverId, userId)
- ?: return IntentResult.Error("Error restoring user")
- if (current.user.isProtected) {
+ is RestoredSession.Success -> {
+ if (result.currentUser.user.isProtected) {
IntentResult.Error("Could not auto sign-in, user is protected")
} else {
null
}
- } else {
- IntentResult.Error("Could not auto sign-in, specify a server & user")
}
- } else {
- IntentResult.Error("Auto sign-in not enabled or user is protected")
}
+ } else {
+ IntentResult.Error("Auto sign-in not enabled")
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
index 01ed9dd2..a60bf7c4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt
@@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services
import android.content.Context
+import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.lifecycleScope
@@ -13,6 +14,7 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
+import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.util.ExceptionHandler
@@ -52,12 +54,8 @@ class LatestNextUpWorker
try {
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active
- var currentUser = serverRepository.current.value
- if (currentUser == null) {
- serverRepository.restoreSession(serverId, userId)
- currentUser = serverRepository.current.value
- }
- if (currentUser == null) {
+ val result = serverRepository.restoreLastSession()
+ if (result !is RestoredSession.Success) {
Timber.w("No user found during run")
return Result.failure()
}
@@ -91,7 +89,7 @@ class LatestNextUpSchedulerService
"SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}",
)
- // Exposed for testing
+ @VisibleForTesting
internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO
init {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
index ec6e9d2c..16ac4edd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt
@@ -122,7 +122,11 @@ class MusicService
mutex.withLock {
if (mediaSession == null) {
Timber.i("Starting music MediaSession")
- mediaSession = MediaSession.Builder(context, player).build()
+ mediaSession =
+ MediaSession
+ .Builder(context, player)
+ .setCallback(TrustAllControllersCallback)
+ .build()
activityTracker =
TrackActivityPlaybackListener(api, player) {
state.value.currentItemId?.let { itemId ->
@@ -327,6 +331,7 @@ class MusicService
imageUrlService.getItemImageUrl(
itemId = albumId,
imageType = ImageType.PRIMARY,
+ tag = audio.data.albumPrimaryImageTag,
)
}
return MediaItem
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
index 9b16718d..a2fd0eb7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
@@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener
import androidx.media3.extractor.DefaultExtractorsFactory
+import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -32,9 +33,15 @@ import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.get
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
+import com.github.damontecres.wholphin.util.BitstreamFilter
import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory
+import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
+import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory
+import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter
import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter
import com.github.damontecres.wholphin.util.WholphinDispatchers
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
@@ -57,6 +64,7 @@ class PlayerFactory
constructor(
@param:ApplicationContext private val context: Context,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
+ private val deviceProfileService: DeviceProfileService,
) {
@Volatile
var currentPlayer: Player? = null
@@ -94,10 +102,16 @@ class PlayerFactory
val preferDolbyVision =
appPreferences.experimentalPreferences
.get { preferDolbyVisionOverHdr10Plus } ?: false
+ val convertDolbyVisionProfile7 =
+ DolbyVisionProfile7Conversion.isWanted(
+ appPreferences.experimentalPreferences,
+ deviceProfileService.mediaCodecCapabilitiesTest,
+ )
Timber.v(
- "extensions=%s, assPlaybackMode=%s",
+ "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s",
extensions,
prefs.overrides.assPlaybackMode,
+ convertDolbyVisionProfile7,
)
val rendererMode =
when (extensions) {
@@ -108,9 +122,18 @@ class PlayerFactory
}
val dataSourceFactory = DefaultDataSource.Factory(context)
val extractorsFactory = createExtractorsFactory()
+
+ // Applied outermost so that a profile 7 track is advertised as 8.1 whichever
+ // extractor, media3's own or libass's Matroska replacement, produced it
+ fun ExtractorsFactory.forDolbyVision(): ExtractorsFactory =
+ if (convertDolbyVisionProfile7) DolbyVisionProfile7ExtractorsFactory(this) else this
var renderersFactory: RenderersFactory =
- WholphinRenderersFactory(context, decodeAv1, preferDolbyVision)
- .setEnableDecoderFallback(true)
+ WholphinRenderersFactory(
+ context,
+ decodeAv1,
+ preferDolbyVision,
+ convertDolbyVisionProfile7,
+ ).setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode)
val mediaSourceFactory =
@@ -126,15 +149,16 @@ class PlayerFactory
renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
DefaultMediaSourceFactory(
dataSourceFactory,
- extractorsFactory.withAssMkvSupport(
- assSubtitleParserFactory,
- assHandler,
- ),
+ extractorsFactory
+ .withAssMkvSupport(
+ assSubtitleParserFactory,
+ assHandler,
+ ).forDolbyVision(),
).setSubtitleParserFactory(assSubtitleParserFactory)
} else {
DefaultMediaSourceFactory(
dataSourceFactory,
- extractorsFactory,
+ extractorsFactory.forDolbyVision(),
)
}
val disableAudioOffload =
@@ -160,8 +184,9 @@ class PlayerFactory
AudioAttributes
.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
+ .setUsage(C.USAGE_MEDIA)
.build(),
- false,
+ true,
)
}
}
@@ -246,9 +271,29 @@ class PlayerFactory
fun createMediaSession(player: Player) =
MediaSession
.Builder(context, player)
+ .setCallback(TrustAllControllersCallback)
.build()
}
+/**
+ * Since media3 1.11, untrusted controllers (e.g. Google Home's Cast bridge, `com.google.android.apps.mediashell`)
+ * only get read-only player commands by default, so play/pause from them is silently dropped.
+ * Restore the pre-1.11 behavior of granting all player commands to every controller.
+ */
+@OptIn(UnstableApi::class)
+object TrustAllControllersCallback : MediaSession.Callback {
+ override fun onConnectAsync(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ ): ListenableFuture =
+ Futures.immediateFuture(
+ MediaSession.ConnectionResult
+ .AcceptedResultBuilder(session, controller)
+ .setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS)
+ .build(),
+ )
+}
+
val Player.isReleased: Boolean
get() {
return when (this) {
@@ -268,6 +313,7 @@ class WholphinRenderersFactory(
context: Context,
private val av1Enabled: Boolean,
private val preferDolbyVisionOverHdr10Plus: Boolean = false,
+ private val convertDolbyVisionProfile7: Boolean = false,
) : DefaultRenderersFactory(context) {
@OptIn(ExperimentalApi::class)
override fun buildVideoRenderers(
@@ -280,12 +326,16 @@ class WholphinRenderersFactory(
allowedVideoJoiningTimeMs: Long,
out: ArrayList,
) {
+ // The conversion goes first: it may shorten the access unit, and the masking then only
+ // has to walk what is left of it
+ val bitstreamFilters =
+ buildList {
+ if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter())
+ if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter())
+ }
val videoCodecAdapterFactory =
- if (preferDolbyVisionOverHdr10Plus) {
- BitstreamFilteringCodecAdapterFactory(
- codecAdapterFactory,
- listOf(Hdr10PlusMaskingFilter()),
- )
+ if (bitstreamFilters.isNotEmpty()) {
+ BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters)
} else {
codecAdapterFactory
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
index 90d9526b..0d0eb0b6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt
@@ -9,20 +9,24 @@ import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission
+import com.github.damontecres.wholphin.data.model.SeerrPluginLogin
+import com.github.damontecres.wholphin.data.model.SeerrPluginSettings
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
import com.github.damontecres.wholphin.util.LoadingState
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.ImageType
+import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@@ -39,26 +43,25 @@ class SeerrServerRepository
private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
- private val _connection =
- MutableStateFlow(SeerrConnectionStatus.NotConfigured)
- val connection: StateFlow = _connection
+ private val _connection = MutableStateFlow(SeerrState())
+ val connection: StateFlow = _connection
val current: Flow =
- _connection.map { (it as? SeerrConnectionStatus.Success)?.current }
+ _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current }
val currentServer: Flow =
- connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
+ connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow =
- connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
+ connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user }
val currentUserId: Flow = current.map { it?.config?.id }
/**
* Whether Seerr integration is currently active of not
*/
val active: Flow =
- connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
+ connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active }
fun clear() {
- _connection.update { SeerrConnectionStatus.NotConfigured }
+ _connection.update { SeerrState() }
seerrApi.update("", null)
}
@@ -67,7 +70,7 @@ class SeerrServerRepository
user: SeerrUser,
exception: Exception,
) {
- _connection.update { SeerrConnectionStatus.Error(server, user, exception) }
+ _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) }
seerrApi.update("", null)
}
@@ -78,8 +81,9 @@ class SeerrServerRepository
) {
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
_connection.update {
- SeerrConnectionStatus.Success(
- CurrentSeerr(server, user, userConfig, publicSettings),
+ it.copy(
+ connection =
+ SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)),
)
}
}
@@ -169,7 +173,7 @@ class SeerrServerRepository
suspend fun removeServerForCurrentUser(): Boolean {
val user =
- when (val conn = connection.first()) {
+ when (val conn = connection.value.connection) {
SeerrConnectionStatus.NotConfigured -> return false
is SeerrConnectionStatus.Error -> conn.user
is SeerrConnectionStatus.Success -> conn.current.user
@@ -178,6 +182,81 @@ class SeerrServerRepository
clear()
return rows > 0
}
+
+ /**
+ * Given the [SeerrPluginSettings], try to connect to the Seerr server
+ *
+ * @return true if successfully updated the SeerrApi and logged in
+ */
+ suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean {
+ val currentUser = serverRepository.currentUser ?: return false
+ if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) {
+ val server =
+ SeerrServer(
+ id = -1,
+ url = settings.serverUrl,
+ )
+ val user =
+ SeerrUser(
+ jellyfinUserRowId = currentUser.rowId,
+ serverId = server.id,
+ authMethod =
+ when (settings.login) {
+ is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY
+ is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL
+ },
+ username =
+ when (settings.login) {
+ is SeerrPluginLogin.ApiKey -> null
+ is SeerrPluginLogin.Local -> settings.login.local.username
+ },
+ password = null,
+ credential = null,
+ )
+ try {
+ val userConfig =
+ when (settings.login) {
+ is SeerrPluginLogin.ApiKey -> {
+ Timber.v("maybeAddPluginServer, using API key")
+ seerrApi.update(settings.serverUrl, settings.login.apiKey)
+ seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null)
+ }
+
+ is SeerrPluginLogin.Local -> {
+ Timber.v("maybeAddPluginServer, using local user")
+ seerrApi.update(settings.serverUrl, null)
+ seerrLogin(
+ seerrApi.api,
+ SeerrAuthMethod.LOCAL,
+ settings.login.local.username,
+ settings.login.local.password,
+ )
+ }
+
+ SeerrPluginLogin.None -> {
+ return false
+ }
+ }
+ // Success
+ _connection.update { it.copy(pluginProvided = true) }
+ set(server, user, userConfig)
+ return true
+ } catch (ex: CancellationException) {
+ throw ex
+ } catch (ex: Exception) {
+ clear()
+ Timber.w(ex, "Error adding plugin provided seerr")
+ _connection.update {
+ it.copy(
+ pluginProvided = true,
+ connection = SeerrConnectionStatus.Error(server, user, ex),
+ )
+ }
+ return false
+ }
+ }
+ return false
+ }
}
/**
@@ -185,6 +264,11 @@ class SeerrServerRepository
*/
typealias SeerrUserConfig = User
+data class SeerrState(
+ val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured,
+ val pluginProvided: Boolean = false,
+)
+
sealed interface SeerrConnectionStatus {
data object NotConfigured : SeerrConnectionStatus
@@ -205,11 +289,15 @@ data class CurrentSeerr(
val config: SeerrUserConfig,
val serverConfig: PublicSettings,
) {
+ val requestMovieEnabled: Boolean
+ get() = config.hasPermission(SeerrPermission.REQUEST_MOVIE)
val request4kMovieEnabled: Boolean
get() =
(serverConfig.movie4kEnabled ?: false) &&
config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
+ val requestTvEnabled: Boolean
+ get() = config.hasPermission(SeerrPermission.REQUEST_TV)
val request4kTvEnabled: Boolean
get() =
(serverConfig.series4kEnabled ?: false) &&
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt
new file mode 100644
index 00000000..8b51ad63
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt
@@ -0,0 +1,96 @@
+package com.github.damontecres.wholphin.services
+
+import com.github.damontecres.wholphin.data.model.HomePageSettings
+import com.github.damontecres.wholphin.data.model.SeerrPluginSettings
+import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
+import com.github.damontecres.wholphin.util.WholphinDispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.decodeFromStream
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okio.IOException
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.exception.ApiClientException
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Service for interacting with the companion plugin
+ */
+@Singleton
+class ServerPluginApi
+ @Inject
+ constructor(
+ @param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
+ private val api: ApiClient,
+ ) {
+ private fun createUrl(path: String): String? =
+ api.baseUrl?.let { if (it.endsWith("/")) "${it}wholphin/$path" else "$it/wholphin/$path" }
+
+ private val json get() = HomeSettingsService.jsonParser
+
+ companion object {
+ private const val HOME_CONFIG_PATH = "homesettings"
+ private const val SEERR_CONFIG_PATH = "seerrsettings"
+ }
+
+ /**
+ * Check if the plugin is installed on the server
+ */
+ suspend fun checkInstalled(): Boolean =
+ withContext(WholphinDispatchers.IO) {
+ val url = createUrl("public") ?: return@withContext false
+ val request =
+ Request
+ .Builder()
+ .url(url)
+ .get()
+ .build()
+ try {
+ return@withContext okHttpClient
+ .newCall(request)
+ .execute()
+ .use { it.isSuccessful }
+ } catch (ex: IOException) {
+ Timber.w("Could not connect to server plugin: %s", ex.message)
+ return@withContext false
+ }
+ }
+
+ private suspend inline fun executeRequest(path: String): T? {
+ val url = createUrl(path) ?: return null
+ val request = Request(url.toHttpUrl())
+ return executeRequest(request)
+ }
+
+ @OptIn(ExperimentalSerializationApi::class)
+ private suspend inline fun executeRequest(request: Request): T? =
+ withContext(WholphinDispatchers.IO) {
+ okHttpClient.newCall(request).execute().use { res ->
+ if (res.isSuccessful) {
+ json.decodeFromStream(res.body.byteStream())
+ } else if (res.code == 404) {
+ Timber.w("Plugin 404: %s", request.url.encodedPath)
+ null
+ } else {
+ throw ApiClientException(res.code.toString() + " " + res.body.string())
+ }
+ }
+ }
+
+ /**
+ * Get the default home page settings from the plugin
+ *
+ * @return the home page settings or null if no rows are configured or the server isn't installed
+ */
+ suspend fun fetchHomePageSettings(): HomePageSettings? =
+ executeRequest(HOME_CONFIG_PATH)?.takeIf { it.rows.isNotEmpty() }
+
+ /**
+ * Get the [SeerrPluginSettings] as configured by teh server plugin, or null if none are configured
+ */
+ suspend fun fetchSeerrSettings(): SeerrPluginSettings? = executeRequest(SEERR_CONFIG_PATH)
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
index 09450b34..0b9da9f8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt
@@ -1,15 +1,27 @@
package com.github.damontecres.wholphin.services
-import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.ActivationFlag
import com.github.damontecres.wholphin.data.model.ItemPlayback
-import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
+import com.github.damontecres.wholphin.data.model.RelativeMediaStream
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
+import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
+import com.github.damontecres.wholphin.data.model.TrackFlag
+import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has
import com.github.damontecres.wholphin.data.model.TrackIndex
+import com.github.damontecres.wholphin.data.model.asRelative
import com.github.damontecres.wholphin.preferences.SubtitleModePreference
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.UserProfileSettings
+import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
+import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
+import com.github.damontecres.wholphin.ui.util.ResStringProvider
+import com.github.damontecres.wholphin.ui.util.StringProvider
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
@@ -30,50 +42,69 @@ class StreamChoiceService
@Inject
constructor(
private val serverRepository: ServerRepository,
- private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao,
+ private val seriesTrackChoiceDao: SeriesTrackChoiceDao,
) {
private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration
- suspend fun updateAudio(
+ suspend fun saveSeriesTrackChoice(
dto: BaseItemDto,
- audioLang: String,
- ) = update(dto) {
- it.copy(
- audioLanguage = audioLang,
- )
- }
-
- suspend fun updateSubtitles(
- dto: BaseItemDto,
- subtitleLang: String?,
- subtitlesDisabled: Boolean,
- ) = update(dto) {
- it.copy(
- subtitleLanguage = if (subtitlesDisabled) null else subtitleLang,
- subtitlesDisabled = subtitlesDisabled,
- )
- }
-
- suspend fun update(
- dto: BaseItemDto,
- update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice,
+ type: MediaStreamType,
+ stream: RelativeMediaStream?,
+ activation: ActivationFlag = ActivationFlag.ACTIVATED,
) {
- val seriesId = dto.seriesId
- if (seriesId != null) {
- val userId = serverRepository.currentUser!!.rowId
- val currentPlc =
- playbackLanguageChoiceDao.get(userId, seriesId)
- ?: PlaybackLanguageChoice(userId, seriesId, dto.id)
- val newPlc = update.invoke(currentPlc)
- Timber.v("Saving series PLC: %s", newPlc)
- playbackLanguageChoiceDao.save(newPlc)
- }
+ val type =
+ when (stream?.type ?: type) {
+ MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
+ MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
+ else -> return
+ }
+ val userId = serverRepository.currentUser!!.rowId
+ val newStc =
+ buildList {
+ dto.parentId
+ ?.let { seasonId ->
+ SeriesTrackChoice(
+ userId = userId,
+ itemId = dto.id,
+ parentId = seasonId,
+ type = type,
+ parentType = TrackChoiceParentType.SEASON,
+ stream = stream,
+ activation = activation,
+ )
+ }?.let(::add)
+ dto.seriesId
+ ?.let { seriesId ->
+ SeriesTrackChoice(
+ userId = userId,
+ itemId = dto.id,
+ parentId = seriesId,
+ type = type,
+ parentType = TrackChoiceParentType.SERIES,
+ stream = stream,
+ activation = activation,
+ )
+ }?.let(::add)
+ }
+ Timber.d(
+ "Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s",
+ dto.id,
+ dto.seriesId,
+ dto.parentId,
+ newStc,
+ )
+ seriesTrackChoiceDao.save(newStc)
}
- suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) =
- dto.seriesId?.let {
- playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it)
- }
+ suspend fun saveDisabledSeriesTrackChoice(
+ dto: BaseItemDto,
+ type: MediaStreamType,
+ ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED)
+
+ suspend fun saveOnlyForcedSeriesTrackChoice(
+ dto: BaseItemDto,
+ type: MediaStreamType,
+ ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED)
/**
* Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution
@@ -104,21 +135,15 @@ class StreamChoiceService
*/
suspend fun chooseAudioStream(
source: MediaSourceInfo,
- seriesId: UUID?,
+ item: BaseItemDto,
itemPlayback: ItemPlayback?,
- plc: PlaybackLanguageChoice?,
+ stc: List?,
prefs: UserPreferences,
- ): MediaStream? {
- val plc =
- plc ?: seriesId?.let {
- playbackLanguageChoiceDao.get(
- serverRepository.currentUser!!.rowId,
- it,
- )
- }
+ ): StreamChoiceResult? {
+ val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.AUDIO }
- chooseAudioStream(candidates, itemPlayback, plc, prefs)
+ chooseAudioStream(candidates, itemPlayback, stc, prefs)
}
}
@@ -128,59 +153,70 @@ class StreamChoiceService
fun chooseAudioStream(
candidates: List,
itemPlayback: ItemPlayback?,
- playbackLanguageChoice: PlaybackLanguageChoice?,
+ stc: List,
prefs: UserPreferences,
- ): MediaStream? =
+ ): StreamChoiceResult =
if (itemPlayback?.audioIndexEnabled == true) {
- candidates.firstOrNull { it.index == itemPlayback.audioIndex }
- } else {
- val seriesLang =
- playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() }
- // If the user has chosen a different language for the series, prefer that
- val audioLanguage =
- seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
- if (audioLanguage.isNotNullOrBlank()) {
- val sorted =
- candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels })
- sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
- ?: sorted.firstOrNull { it.language == audioLanguage }
- ?: sorted.firstOrNull { it.isDefault }
- ?: sorted.firstOrNull()
+ candidates.firstOrNull { it.index == itemPlayback.audioIndex } with
+ StreamChoiceReason.Item(
+ itemPlayback,
+ )
+ } else if (stc.isNotEmpty()) {
+ val first = stc.first()
+ val result = scoreStreams(candidates, first)
+ if (result == null) {
+ null with StreamChoiceReason.from(first)
+ } else if (result.isEmpty() && stc.size > 1) {
+ // SeriesTrackChoice did not apply to any streams, but there are more options
+ chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs)
+ } else if (result.isEmpty()) {
+ // SeriesTrackChoice did not apply to any streams, so use regular selection logic
+ Timber.v("No audio STC applied")
+ chooseAudioStream(candidates, itemPlayback, emptyList(), prefs)
} else {
- candidates.firstOrNull { it.isDefault }
- ?: candidates.firstOrNull()
+ // Otherwise, use the best scored stream
+ Timber.v("Using audio STC from %s", first.parentId)
+ result.first().stream with StreamChoiceReason.from(first, result)
}
+ } else {
+ val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig)
+ val stream =
+ if (audioLanguage.isNotNullOrBlank()) {
+ val sorted =
+ candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels })
+ sorted.firstOrNull { it.language == audioLanguage && it.isDefault }
+ ?: sorted.firstOrNull { it.language == audioLanguage }
+ ?: sorted.firstOrNull { it.isDefault }
+ ?: sorted.firstOrNull()
+ } else {
+ candidates.firstOrNull { it.isDefault }
+ ?: candidates.firstOrNull()
+ }
+ stream with StreamChoiceReason.UserPreferences
}
/**
* Returns the subtitle stream that should play
*/
suspend fun chooseSubtitleStream(
+ item: BaseItemDto,
source: MediaSourceInfo,
audioStream: MediaStream?,
- seriesId: UUID?,
itemPlayback: ItemPlayback?,
- plc: PlaybackLanguageChoice?,
+ stc: List?,
prefs: UserPreferences,
- ): MediaStream? {
- val plc =
- plc ?: seriesId?.let {
- playbackLanguageChoiceDao.get(
- serverRepository.currentUser!!.rowId,
- it,
- )
- }
- return source.mediaStreams?.letNotEmpty { streams ->
+ ): StreamChoiceResult? =
+ source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
+ val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE)
chooseSubtitleStream(
audioStream?.language,
candidates,
itemPlayback,
- plc,
+ stc,
prefs,
)
}
- }
/**
* Resolves ONLY_FORCED to an actual subtitle track index.
@@ -189,7 +225,7 @@ class StreamChoiceService
suspend fun resolveSubtitleIndex(
source: MediaSourceInfo,
audioStreamIndex: Int?,
- seriesId: UUID?,
+ item: BaseItemDto,
subtitleIndex: Int,
prefs: UserPreferences,
): Int? =
@@ -203,17 +239,17 @@ class StreamChoiceService
val itemPlayback =
ItemPlayback(
userId = serverRepository.currentUser!!.rowId,
- itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution
+ itemId = item.id,
subtitleIndex = TrackIndex.ONLY_FORCED,
)
chooseSubtitleStream(
+ item = item,
source = source,
audioStream = audioStream,
- seriesId = seriesId,
itemPlayback = itemPlayback,
- plc = null,
+ stc = null,
prefs = prefs,
- )?.index
+ )?.stream?.index
}
/**
@@ -223,47 +259,81 @@ class StreamChoiceService
audioStreamLang: String?,
candidates: List,
itemPlayback: ItemPlayback?,
- playbackLanguageChoice: PlaybackLanguageChoice?,
+ stc: List,
prefs: UserPreferences,
- ): MediaStream? {
- if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) {
- return null
- }
- val seriesLang =
- playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() }
- val subtitleLanguage =
- seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
- if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) {
- // Client-side manual override: User selected "Only Forced" in player menu
- return findForcedTrack(candidates, subtitleLanguage, audioStreamLang)
- } else if (itemPlayback?.subtitleIndexEnabled == true) {
- return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex }
+ ): StreamChoiceResult {
+ if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) {
+ if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) {
+ return null with StreamChoiceReason.Item(itemPlayback)
+ } else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) {
+ val subtitleLanguage =
+ getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
+ // Client-side manual override: User selected "Only Forced" in player menu
+ return findForcedTrack(
+ candidates,
+ subtitleLanguage,
+ audioStreamLang,
+ ) with StreamChoiceReason.Item(itemPlayback)
+ } else {
+ return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with
+ StreamChoiceReason.Item(itemPlayback)
+ }
+ } else if (stc.isNotEmpty()) {
+ val first = stc.first()
+ return when (first.activation) {
+ ActivationFlag.DISABLED -> {
+ null with StreamChoiceReason.from(first)
+ }
+
+ ActivationFlag.ONLY_FORCED -> {
+ val subtitleLanguage =
+ getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
+ val stream =
+ findForcedTrack(
+ candidates,
+ subtitleLanguage,
+ audioStreamLang,
+ )
+ if (stream != null) {
+ stream with StreamChoiceReason.from(first)
+ } else {
+ handleSubtitleStreamChoiceResult(
+ result = emptyList(),
+ audioStreamLang = audioStreamLang,
+ candidates = candidates,
+ itemPlayback = itemPlayback,
+ stc = stc,
+ prefs = prefs,
+ )
+ }
+ }
+
+ ActivationFlag.ACTIVATED -> {
+ val result =
+ scoreStreams(candidates, first)
+ ?: throw IllegalStateException("scoreStreams should not return null for activated")
+ handleSubtitleStreamChoiceResult(
+ result = result,
+ audioStreamLang = audioStreamLang,
+ candidates = candidates,
+ itemPlayback = itemPlayback,
+ stc = stc,
+ prefs = prefs,
+ )
+ }
+ }
} else {
+ val subtitleLanguage =
+ getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig)
val subtitleMode =
- when {
- playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> {
- // User has chosen a series level subtitle language, so override their normal
- // subtitle mode to display that language
- SubtitlePlaybackMode.ALWAYS
- }
-
- playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> {
- // Series level settings disables subtitles
- SubtitlePlaybackMode.NONE
- }
-
- else -> {
- // Fallback to the user's preference
- when (prefs.userPreferences?.subtitleMode) {
- SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
- SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
- SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
- SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
- SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
- SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
- null -> SubtitlePlaybackMode.DEFAULT
- }
- }
+ when (prefs.userPreferences?.subtitleMode) {
+ SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode
+ SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT
+ SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART
+ SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED
+ SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS
+ SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE
+ null -> SubtitlePlaybackMode.DEFAULT
} ?: SubtitlePlaybackMode.DEFAULT
val candidates =
candidates
@@ -277,73 +347,104 @@ class StreamChoiceService
}.thenByDescending { it.isForced && it.language.isUnknown }
.thenByDescending { it.isForced },
)
- return when (subtitleMode) {
- SubtitlePlaybackMode.ALWAYS -> {
- if (subtitleLanguage.isNotNullOrBlank()) {
- candidates.firstOrNull {
- // Prefer non-forced first
- !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
- } ?: candidates.firstOrNull {
- it.language.equalsLangOrUnknown(subtitleLanguage)
- }
- } else {
- candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
- }
- }
-
- SubtitlePlaybackMode.ONLY_FORCED -> {
- if (subtitleLanguage.isNotNullOrBlank()) {
- candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
- ?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
- } else {
- candidates.firstOrNull { it.isForced }
- }
- }
-
- SubtitlePlaybackMode.SMART -> {
- if (subtitleLanguage.isNotNullOrBlank()) {
- val audioLanguage = userConfig?.audioLanguagePreference
- if (
- // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
- (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
- // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
- (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
- ) {
- candidates.firstOrNull { it.language == subtitleLanguage }
- ?: candidates.firstOrNull { it.language.isUnknown }
+ val stream =
+ when (subtitleMode) {
+ SubtitlePlaybackMode.ALWAYS -> {
+ if (subtitleLanguage.isNotNullOrBlank()) {
+ candidates.firstOrNull {
+ // Prefer non-forced first
+ !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage)
+ } ?: candidates.firstOrNull {
+ it.language.equalsLangOrUnknown(subtitleLanguage)
+ }
} else {
- // Otherwise, show forced subtitles in preferred lang
- candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
- ?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
+ candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull()
}
- } else {
- candidates.firstOrNull { it.isDefault }
+ }
+
+ SubtitlePlaybackMode.ONLY_FORCED -> {
+ if (subtitleLanguage.isNotNullOrBlank()) {
+ candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
+ ?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
+ } else {
+ candidates.firstOrNull { it.isForced }
+ }
+ }
+
+ SubtitlePlaybackMode.SMART -> {
+ if (subtitleLanguage.isNotNullOrBlank()) {
+ val audioLanguage = userConfig?.audioLanguagePreference
+ if (
+ // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different
+ (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) ||
+ // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio
+ (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang)
+ ) {
+ candidates.firstOrNull { it.language == subtitleLanguage }
+ ?: candidates.firstOrNull { it.language.isUnknown }
+ } else {
+ // Otherwise, show forced subtitles in preferred lang
+ candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
+ ?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
+ }
+ } else {
+ candidates.firstOrNull { it.isDefault }
+ }
+ }
+
+ SubtitlePlaybackMode.DEFAULT -> {
+ if (subtitleLanguage.isNotNullOrBlank()) {
+ candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
+ ?: candidates.firstOrNull { it.isDefault || it.isForced }
+ } else {
+ candidates.firstOrNull { it.isDefault || it.isForced }
+ }
+ }
+
+ SubtitlePlaybackMode.NONE -> {
+ null
}
}
-
- SubtitlePlaybackMode.DEFAULT -> {
- if (subtitleLanguage.isNotNullOrBlank()) {
- candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
- ?: candidates.firstOrNull { it.isDefault || it.isForced }
- } else {
- candidates.firstOrNull { it.isDefault || it.isForced }
- }
- }
-
- SubtitlePlaybackMode.NONE -> {
- null
- }
- }
+ return stream with StreamChoiceReason.UserPreferences
}
}
- /** Returns true if the track is forced (via metadata flag or title patterns). */
- private fun isForcedOrSigns(track: MediaStream): Boolean {
- if (track.isForced) return true
- val title = track.title ?: track.displayTitle ?: return false
- return title.contains("forced", ignoreCase = true) ||
- title.contains("signs", ignoreCase = true) ||
- title.contains("songs", ignoreCase = true)
+ /**
+ * Recursively calls [chooseSubtitleStream] based on [result] and [stc]
+ */
+ private fun handleSubtitleStreamChoiceResult(
+ result: List,
+ audioStreamLang: String?,
+ candidates: List,
+ itemPlayback: ItemPlayback?,
+ stc: List,
+ prefs: UserPreferences,
+ ): StreamChoiceResult {
+ val first = stc.first()
+ return if (result.isEmpty() && stc.size > 1) {
+ // SeriesTrackChoice did not apply to any streams, but there are more options
+ chooseSubtitleStream(
+ audioStreamLang,
+ candidates,
+ itemPlayback,
+ stc.subList(1, stc.size),
+ prefs,
+ )
+ } else if (result.isEmpty()) {
+ // SeriesTrackChoice did not apply to any streams, so use regular selection logic
+ Timber.v("No subtitle STC applied")
+ chooseSubtitleStream(
+ audioStreamLang,
+ candidates,
+ itemPlayback,
+ emptyList(),
+ prefs,
+ )
+ } else {
+ // Otherwise, use the best scored stream
+ Timber.v("Using subtitle STC from %s", first.parentId)
+ result.first().stream with StreamChoiceReason.from(first, result)
+ }
}
/** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */
@@ -367,19 +468,92 @@ class StreamChoiceService
// 3. Unknown language forced track
return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) }
}
+
+ suspend fun getSeriesTrackChoices(
+ seriesId: UUID?,
+ seasonId: UUID?,
+ type: SeriesTrackChoiceType,
+ ): List {
+ val userId = serverRepository.currentUser?.rowId ?: return emptyList()
+ return when {
+ seriesId != null && seasonId != null -> {
+ seriesTrackChoiceDao.get(
+ userId = userId,
+ seasonId = seasonId,
+ seriesId = seriesId,
+ type = type,
+ )
+ }
+
+ seriesId != null -> {
+ seriesTrackChoiceDao.getBySeriesId(
+ userId = userId,
+ seriesId = seriesId,
+ type = type,
+ )
+ }
+
+ seasonId != null -> {
+ seriesTrackChoiceDao.getBySeasonId(
+ userId = userId,
+ seasonId = seasonId,
+ type = type,
+ )
+ }
+
+ else -> {
+ emptyList()
+ }
+ }
+ }
+
+ suspend fun getSeriesTrackChoices(
+ item: BaseItemDto,
+ type: SeriesTrackChoiceType,
+ ): List = getSeriesTrackChoices(item.seriesId, item.parentId, type)
+
+ suspend fun getSeriesTrackChoices(
+ item: BaseItemDto,
+ type: MediaStreamType,
+ ): List =
+ getSeriesTrackChoices(
+ seriesId = item.seriesId,
+ seasonId = item.parentId,
+ type =
+ when (type) {
+ MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO
+ MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE
+ else -> return emptyList()
+ },
+ )
}
private val String?.isUnknown: Boolean
get() =
- this == null ||
+ this.isNullOrBlank() ||
this.equals("unknown", true) ||
this.equals("und", true) ||
this.equals("undetermined", true) ||
this.equals("mul", true) ||
this.equals("zxx", true)
+private val String?.isNotUnknown: Boolean
+ get() = !this.isUnknown
+
private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown
+private fun String?.equalsLangExact(lang: String?): Boolean = this.isNotUnknown && lang.isNotUnknown && equals(lang, ignoreCase = true)
+
+/** Returns true if the track is forced (via metadata flag or title patterns). */
+fun isForcedOrSigns(track: MediaStream): Boolean = track.isForced || isSigns(track)
+
+fun isSigns(track: MediaStream): Boolean {
+ val title = track.title ?: track.displayTitle ?: return false
+ return title.contains("forced", ignoreCase = true) ||
+ title.contains("signs", ignoreCase = true) ||
+ title.contains("songs", ignoreCase = true)
+}
+
/**
* Based on the user's preferences, get their preferred language for audio or subtitles
*
@@ -412,3 +586,153 @@ fun getPreferredLanguage(
else -> pref
}?.takeIf { it.isNotNullOrBlank() }
}
+
+/**
+ * Scores streams based the user's explicit [SeriesTrackChoice]
+ *
+ * @return sorted list of preferred streams or null if stream should be disabled
+ */
+fun scoreStreams(
+ streams: List,
+ choice: SeriesTrackChoice,
+): List? {
+ if (choice.activation == ActivationFlag.DISABLED) {
+ return null
+ }
+ val streams =
+ streams.filter {
+ if (
+ (choice.activation == ActivationFlag.ONLY_FORCED || choice.has(TrackFlag.FORCED)) &&
+ !isForcedOrSigns(it)
+ ) {
+ // Filter out non-forced tracks if the user only wants forced
+ return@filter false
+ }
+ if (choice.language.isNotUnknown &&
+ !choice.language.equalsLangExact(it.language)
+ ) {
+ // Filter out languages that do not match
+ return@filter false
+ }
+ // Filter by type
+ choice.type.mediaStreamType == it.type
+ }
+ val scored =
+ streams
+ .asRelative()
+ .map { s ->
+ var score = 0
+
+ // Prefer exact language match over unknown
+ if (choice.language.equalsLangExact(s.language)) score += 10_000
+
+ if (s.isForced && choice.has(TrackFlag.FORCED)) {
+ score += 1_000
+ }
+ if (choice.has(TrackFlag.SIGNS) && isSigns(s.stream)) {
+ score += 500
+ }
+ if (s.isDefault && choice.has(TrackFlag.DEFAULT)) {
+ score += 500
+ }
+
+ if (s.isHearingImpaired && choice.has(TrackFlag.SDH)) {
+ score += 500
+ } else if (choice.has(TrackFlag.SDH) && s.isForced) {
+ // If user wants SDH, prefer non-forced "regular" over forced
+ score -= 10
+ }
+
+ if (s.isExternal && choice.has(TrackFlag.EXTERNAL)) {
+ score += 100
+ }
+
+ if (s.relativeIndex == choice.relativeTrackIndex) {
+ score += 100
+ }
+ if (s.stream.title == choice.title) {
+ score += 10
+ }
+
+ if (choice.audioChannels != null && s.stream.channels == choice.audioChannels) {
+ score += 100
+ } else if (choice.audioChannels != null && s.stream.channels.gt(choice.audioChannels)) {
+ score += 50
+ }
+
+ if (s.isDefault) {
+ // All else being equal, prefer the default track
+ score += 1
+ }
+ ScoredMediaStream(score, s.stream)
+ }.sortedByDescending { it.score }
+ return scored
+}
+
+data class StreamChoiceResult(
+ val stream: MediaStream?,
+ val reason: StreamChoiceReason,
+)
+
+data class ScoredMediaStream(
+ val score: Int,
+ val stream: MediaStream,
+)
+
+private infix fun MediaStream?.with(reason: StreamChoiceReason) = StreamChoiceResult(this, reason)
+
+/**
+ * Why a particular [MediaStream] was chosen
+ */
+sealed interface StreamChoiceReason {
+ val description: StringProvider
+ val scores: List get() = emptyList()
+
+ data object Unknown : StreamChoiceReason {
+ override val description: StringProvider = ResStringProvider(R.string.unknown)
+ }
+
+ data object UserPreferences : StreamChoiceReason {
+ override val description: StringProvider =
+ ResStringProvider(R.string.profile_specific_settings)
+ }
+
+ data class Series(
+ val stc: SeriesTrackChoice,
+ override val scores: List,
+ ) : StreamChoiceReason {
+ override val description: StringProvider =
+ ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER)
+ }
+
+ data class Season(
+ val stc: SeriesTrackChoice,
+ override val scores: List,
+ ) : StreamChoiceReason {
+ override val description: StringProvider =
+ ResProviderStringProvider(R.string.chosen_by, SEASON_PROVIDER)
+ }
+
+ data class Item(
+ val itemPlayback: ItemPlayback,
+ ) : StreamChoiceReason {
+ override val description: StringProvider =
+ ResProviderStringProvider(R.string.chosen_by, USER_PROVIDER)
+ }
+
+ companion object {
+ fun from(
+ stc: SeriesTrackChoice,
+ scores: List = emptyList(),
+ ) = when (stc.parentType) {
+ TrackChoiceParentType.SERIES -> Series(stc, scores)
+ TrackChoiceParentType.SEASON -> Season(stc, scores)
+ TrackChoiceParentType.DEPRECATED -> Unknown
+ TrackChoiceParentType.UNKNOWN -> Unknown
+ }
+
+ private val USER_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.user) }
+ private val SERIES_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.series) }
+ private val SEASON_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.tv_season) }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
index aa64058d..e0dd472f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
+import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -63,12 +64,8 @@ class SuggestionsWorker
}
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
- var currentUser = serverRepository.current.value
- if (currentUser == null) {
- serverRepository.restoreSession(serverId, userId)
- currentUser = serverRepository.current.value
- }
- if (currentUser == null) {
+ val result = serverRepository.restoreLastSession()
+ if (result !is RestoredSession.Success) {
Timber.w("No user found during run")
return Result.failure()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt
index 06115982..b208d663 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt
@@ -22,6 +22,7 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
+import kotlin.coroutines.cancellation.CancellationException
/**
* Listens for JF user switching in the app to also switch other settings like Seerr user/server
@@ -36,6 +37,7 @@ class UserSwitchListener
private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi,
private val homeSettingsService: HomeSettingsService,
+ private val serverPluginApi: ServerPluginApi,
) {
init {
context as AppCompatActivity
@@ -65,49 +67,78 @@ class UserSwitchListener
AppCompatDelegate.setApplicationLocales(localeList)
}
- // Check for home settings
+ // Check if plugin is installed, then for home settings & seerr settings
launchIO {
- homeSettingsService.loadCurrentSettings(user.id)
- }
- if (BuildConfig.DISCOVER_ENABLED) {
- // Check for seerr server
+ val serverPluginInstalled =
+ try {
+ serverPluginApi.checkInstalled()
+ } catch (ex: CancellationException) {
+ throw ex
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error checking for server plugin")
+ false
+ }
+ Timber.i("Server plugin installed: %s", serverPluginInstalled)
+ serverRepository.serverPluginInstalled.value = serverPluginInstalled
+
launchIO {
- seerrServerDao
- .getUsersByJellyfinUser(user.rowId)
- .lastOrNull()
- ?.let { seerrUser ->
- val server =
- seerrServerDao.getServer(seerrUser.serverId)?.server
- if (server != null) {
- Timber.i("Found a seerr user & server")
- try {
- seerrApi.update(server.url, seerrUser.credential)
- val userConfig =
- if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
- seerrLogin(
- seerrApi.api,
- seerrUser.authMethod,
- seerrUser.username,
- seerrUser.password,
- )
- } else {
- seerrApi.api.usersApi.authMeGet()
- }
- seerrServerRepository.set(
- server,
- seerrUser,
- userConfig,
- )
- } catch (ex: Exception) {
- Timber.w(
- ex,
- "Error logging into %s",
- server.url,
- )
- seerrServerRepository.error(server, seerrUser, ex)
+ // Check for home settings
+ homeSettingsService.loadCurrentSettings(user)
+ }
+
+ if (BuildConfig.DISCOVER_ENABLED) {
+ // Check for seerr server
+ launchIO {
+ if (serverPluginInstalled) {
+ serverPluginApi.fetchSeerrSettings()?.let {
+ Timber.d("Trying seerr auto login")
+ val result = seerrServerRepository.usePluginProvidedServer(it)
+ if (result) {
+ // Successfully used server plugin provided seerr
+ Timber.i("Seerr auto login succeeded")
+ return@launchIO
}
}
}
+ Timber.v("Checking for existing configured seerr server")
+ // Either no server plugin seerr or there was an issue with it
+ seerrServerDao
+ .getUsersByJellyfinUser(user.rowId)
+ .lastOrNull()
+ ?.let { seerrUser ->
+ val server =
+ seerrServerDao.getServer(seerrUser.serverId)?.server
+ if (server != null) {
+ Timber.i("Found a seerr user & server")
+ try {
+ seerrApi.update(server.url, seerrUser.credential)
+ val userConfig =
+ if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
+ seerrLogin(
+ seerrApi.api,
+ seerrUser.authMethod,
+ seerrUser.username,
+ seerrUser.password,
+ )
+ } else {
+ seerrApi.api.usersApi.authMeGet()
+ }
+ seerrServerRepository.set(
+ server,
+ seerrUser,
+ userConfig,
+ )
+ } catch (ex: Exception) {
+ Timber.w(
+ ex,
+ "Error logging into %s",
+ server.url,
+ )
+ seerrServerRepository.error(server, seerrUser, ex)
+ }
+ }
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
index 06607cf9..3a114017 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt
@@ -4,6 +4,8 @@ import android.content.Context
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.MostRecentServerProvider
+import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
@@ -188,6 +190,12 @@ object AppModule {
fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient)
+
+ @Provides
+ @Singleton
+ fun mostRecentServerProvider(
+ @ApplicationContext context: Context,
+ ): MostRecentServerProvider = MostRecentServerSharedPreferences(context)
}
@Module
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
index 29688664..78270a9b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt
@@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
+import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
@@ -79,6 +80,10 @@ object DatabaseModule {
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
+ @Provides
+ @Singleton
+ fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
+
@Provides
@Singleton
fun userPreferencesDataStore(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
index d3dbf4c7..a4d73caa 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt
@@ -19,6 +19,7 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
@@ -70,12 +71,8 @@ class TvProviderWorker
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active
- var currentUser = serverRepository.current.value
- if (currentUser == null) {
- serverRepository.restoreSession(serverId, userId)
- currentUser = serverRepository.current.value
- }
- if (currentUser == null) {
+ val result = serverRepository.restoreLastSession()
+ if (result !is RestoredSession.Success) {
Timber.w("No user found during run")
return Result.failure()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
index f774a0da..a284ef0b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt
@@ -8,6 +8,9 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
+import com.github.damontecres.wholphin.preferences.AppPreference.Companion.MEGA_BIT
+import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
+import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType
@@ -205,6 +208,13 @@ fun formatBytes(
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
+fun formatMaxBitrate(bitrate: Long) =
+ if (bitrate < MEGA_BIT) {
+ "${bitrate / 1024} kbps"
+ } else {
+ "${bitrate / MEGA_BIT} Mbps"
+ }
+
@get:StringRes
val MediaSegmentType.stringRes: Int
get() =
@@ -300,3 +310,55 @@ fun Resources.formatDuration(duration: Duration): String =
else -> getString(R.string.duration_seconds, seconds)
}
}
+
+fun Resources.serverSupportedMessage(
+ serverVersionSupported: ServerVersionSupported,
+ serverVersion: ServerVersion?,
+): String? {
+ val errorMsg =
+ when (serverVersionSupported) {
+ ServerVersionSupported.SUPPORTED -> {
+ null
+ }
+
+ ServerVersionSupported.NOT_SUPPORTED,
+ ServerVersionSupported.UNKNOWN,
+ -> {
+ getString(R.string.server_version_not_supported)
+ }
+
+ ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> {
+ getString(R.string.server_version_not_supported_future)
+ }
+ }
+ return errorMsg?.let {
+ val msg =
+ if (serverVersion != null) {
+ "$it: $serverVersion"
+ } else {
+ it + ": " + getString(R.string.unknown)
+ }
+ msg + "\n" +
+ when (serverVersionSupported) {
+ ServerVersionSupported.SUPPORTED -> {
+ ""
+ }
+
+ ServerVersionSupported.NOT_SUPPORTED,
+ ServerVersionSupported.UNKNOWN,
+ -> {
+ getString(
+ R.string.server_version_required,
+ WholphinApplication.minimumServerVersion.toString(),
+ )
+ }
+
+ ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> {
+ getString(
+ R.string.server_version_required,
+ WholphinApplication.newMinimumServerVersion.toString(),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
index 1d70553e..af67f12c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt
@@ -44,7 +44,6 @@ import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.main.HomePageHeader
-import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
@@ -53,19 +52,18 @@ import kotlin.time.Duration
@OptIn(ExperimentalFoundationApi::class)
@Composable
-fun CollectionFolderGrid(
+fun CollectionFolderViewScope.CollectionFolderGrid(
preferences: UserPreferences,
- collectionType: CollectionType?,
focusedItem: BaseItem?,
items: List,
sortAndDirection: SortAndDirection,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int,
- viewOptions: ViewOptions,
onClickPlay: (Int, BaseItem) -> Unit,
initialPosition: Int,
gridFocusRequester: FocusRequester,
+ cardContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit,
modifier: Modifier = Modifier,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
@@ -99,18 +97,8 @@ fun CollectionFolderGrid(
modifier = Modifier.fillMaxSize(),
initialPosition = initialPosition,
positionCallback = positionCallback,
- cardContent = { (item, index, onClick, onLongClick, widthPx, mod) ->
- GridCard(
- item = item,
- onClick = onClick,
- onLongClick = onLongClick,
- imageContentScale = viewOptions.contentScale.scale,
- imageAspectRatio = viewOptions.aspectRatio.ratio,
- imageType = viewOptions.imageType,
- showTitle = viewOptions.showTitles,
- fillWidth = widthPx,
- modifier = mod,
- )
+ cardContent = { details ->
+ cardContent.invoke(this@CollectionFolderGrid, details)
},
columns = viewOptions.columns,
spacing = viewOptions.spacing.dp,
@@ -210,10 +198,10 @@ val CollectionType.baseItemKinds: List
listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE)
}
- CollectionType.HOMEVIDEOS -> {
- listOf(BaseItemKind.VIDEO)
- listOf(BaseItemKind.PHOTO)
- listOf(BaseItemKind.PHOTO_ALBUM)
+ CollectionType.PHOTOS,
+ CollectionType.HOMEVIDEOS,
+ -> {
+ listOf(BaseItemKind.VIDEO, BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
}
CollectionType.MUSIC -> {
@@ -236,10 +224,6 @@ val CollectionType.baseItemKinds: List
listOf(BaseItemKind.PLAYLIST)
}
- CollectionType.PHOTOS -> {
- listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
- }
-
CollectionType.LIVETV -> {
listOf(
BaseItemKind.TV_CHANNEL,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt
index 967ddb07..57ff8957 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt
@@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator
import com.github.damontecres.wholphin.ui.cards.WatchedIcon
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
+import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.ifElse
@@ -75,7 +77,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.extensions.ticks
@Composable
-fun CollectionFolderList(
+fun CollectionFolderViewScope.CollectionFolderList(
preferences: UserPreferences,
collectionType: CollectionType?,
focusedItem: BaseItem?,
@@ -84,10 +86,10 @@ fun CollectionFolderList(
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int,
- viewOptions: ViewOptions,
onClickPlay: (Int, BaseItem) -> Unit,
initialPosition: Int,
gridFocusRequester: FocusRequester,
+ itemContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit,
modifier: Modifier = Modifier,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
@@ -153,10 +155,8 @@ fun CollectionFolderList(
).padding(8.dp),
)
}
- CollectionFolderContent(
+ this@CollectionFolderList.CollectionFolderListContent(
items = items,
- viewOptions = viewOptions,
- collectionType = collectionType,
listState = listState,
position = position,
positionFocusRequester = positionFocusRequester,
@@ -167,6 +167,7 @@ fun CollectionFolderList(
},
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
+ itemContent = itemContent,
modifier =
Modifier
.weight(1f)
@@ -266,44 +267,43 @@ fun CollectionFolderListDetails(
}
@Composable
-fun CollectionFolderContent(
+fun CollectionFolderViewScope.CollectionFolderListContent(
items: List,
- viewOptions: ViewOptions,
- collectionType: CollectionType?,
listState: LazyListState,
position: Int,
positionFocusRequester: FocusRequester,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
onPositionChange: (Int) -> Unit,
+ itemContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit,
modifier: Modifier = Modifier,
) {
+ val currentPosition by rememberUpdatedState(position)
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp),
modifier = modifier.focusRestorer(positionFocusRequester),
) {
itemsIndexed(items) { index, item ->
- val onClick: () -> Unit =
- remember(index, item) {
- { item?.let { onClickItem.invoke(index, item) } }
+ val details =
+ remember(item, index, onClickItem, onLongClickItem, onPositionChange) {
+ GridItemDetails(
+ item = item,
+ index = index,
+ onClick = { item?.let { onClickItem.invoke(index, item) } },
+ onLongClick = { item?.let { onLongClickItem.invoke(index, item) } },
+ widthPx = -1,
+ mod =
+ Modifier
+ .onFocusChanged {
+ if (it.isFocused) onPositionChange.invoke(index)
+ }.ifElse(
+ index == currentPosition,
+ Modifier.focusRequester(positionFocusRequester),
+ ),
+ )
}
- val onLongClick: () -> Unit =
- remember(index, item) {
- { item?.let { onLongClickItem.invoke(index, item) } }
- }
- CollectionFolderListItem(
- item = item,
- dense = viewOptions.type == ViewOptionsType.DENSE_LIST,
- collectionType = collectionType,
- onClick = onClick,
- onLongClick = onLongClick,
- modifier =
- Modifier
- .onFocusChanged {
- if (it.isFocused) onPositionChange.invoke(index)
- }.ifElse(index == position, Modifier.focusRequester(positionFocusRequester)),
- )
+ itemContent.invoke(this@CollectionFolderListContent, details)
}
}
}
@@ -522,9 +522,12 @@ fun ListItemAlbum(
}
val supportingContent =
remember(item, dense) {
- if (!dense && item?.subtitle != null) {
+ // Album artist is already shown as the overline
+ val subtitle =
+ if (item?.type == BaseItemKind.MUSIC_ALBUM) item.data.productionYear?.toString() else item?.subtitle
+ if (!dense && subtitle != null) {
@Composable {
- Text(item.subtitle ?: "")
+ Text(subtitle)
}
} else {
null
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt
index 52918e57..8445dfa9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt
@@ -54,20 +54,25 @@ import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields
+import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
+import com.github.damontecres.wholphin.ui.util.goToRandom
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler
import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
@@ -89,12 +94,14 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
+import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
@@ -146,6 +153,9 @@ class CollectionFolderViewModel
private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions))
val state: StateFlow = _state
+ private val artistSource: ArtistSource
+ get() = state.value.viewOptions.artistSource
+
var position: Int
get() = savedStateHandle.get("position") ?: 0
set(value) {
@@ -168,15 +178,25 @@ class CollectionFolderViewModel
val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId
libraryDisplayInfoDao.getItem(user, id)
}
+ val ancestorDisplayInfo =
+ if (libraryDisplayInfo?.viewOptions == null && item != null) {
+ getAncestorDisplayInfo(item)
+ } else {
+ null
+ }
_state.update {
it.copy(
- viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
+ viewOptions =
+ libraryDisplayInfo?.viewOptions
+ ?: ancestorDisplayInfo?.viewOptions
+ ?: defaultViewOptions,
)
}
val sortAndDirection =
if (collectionFilter.useSavedLibraryDisplayInfo) {
libraryDisplayInfo?.sortAndDirection
+ ?: ancestorDisplayInfo?.sortAndDirection
} else {
null
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
@@ -232,6 +252,25 @@ class CollectionFolderViewModel
}
}
+ /**
+ * For a sub-folder, returns the saved display info of the closest ancestor that has one
+ */
+ private suspend fun getAncestorDisplayInfo(item: BaseItem): LibraryDisplayInfo? {
+ if (item.type != BaseItemKind.FOLDER || collectionFilter.libraryDisplayInfoIdOverride != null) {
+ return null
+ }
+ val user = serverRepository.currentUser ?: return null
+ return try {
+ api.libraryApi
+ .getAncestors(item.id, user.id)
+ .content
+ .firstNotNullOfOrNull { ancestor -> libraryDisplayInfoDao.getItem(user, ancestor.id) }
+ } catch (ex: Exception) {
+ Timber.w(ex, "Error getting ancestors for %s", item.id)
+ null
+ }
+ }
+
private fun saveLibraryDisplayInfo(
newFilter: GetItemsFilter = state.value.filter,
newSort: SortAndDirection = state.value.sortAndDirection,
@@ -257,7 +296,17 @@ class CollectionFolderViewModel
override fun saveViewOptions(viewOptions: ViewOptions) {
position = 0
+ val artistSourceChanged = viewOptions.artistSource != artistSource
_state.update { it.copy(viewOptions = viewOptions) }
+ if (artistSourceChanged && state.value.filter.override == GetItemsFilterOverride.ARTIST) {
+ loadResults(
+ true,
+ state.value.sortAndDirection,
+ recursive,
+ state.value.filter,
+ useSeriesForPrimary,
+ )
+ }
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
saveLibraryDisplayInfo(viewOptions = viewOptions)
if (!viewOptions.showBackdrop) {
@@ -269,11 +318,19 @@ class CollectionFolderViewModel
override fun onClickRandom() {
viewModelScope.launchIO {
try {
- val random =
- (state.value.items.successValue as? BlockingList)?.randomBlocking()
- Timber.d("Got random item: %s", random?.id)
- random?.destination()?.let {
- navigateTo(random.destination())
+ val state = state.value
+ val list = state.items.successValue as? BlockingList
+ if (list != null) {
+ goToRandom(
+ navigateTo = ::navigateTo,
+ parentId = itemId,
+ filter = state.filter,
+ sortAndDirection = state.sortAndDirection,
+ recursive = recursive,
+ list = list,
+ )
+ } else {
+ Timber.w("state.value.items is not successful: %s", state.items)
}
} catch (ex: CancellationException) {
throw ex
@@ -399,13 +456,27 @@ class CollectionFolderViewModel
}
GetItemsFilterOverride.ARTIST -> {
- ApiRequestPager(
- api,
- createGetArtistsRequest(filter),
- GetArtistsHandler,
- viewModelScope,
- useSeriesForPrimary = useSeriesForPrimary,
- )
+ when (artistSource) {
+ ArtistSource.ARTIST -> {
+ ApiRequestPager(
+ api,
+ createGetArtistsRequest(filter),
+ GetArtistsHandler,
+ viewModelScope,
+ useSeriesForPrimary = useSeriesForPrimary,
+ )
+ }
+
+ ArtistSource.ALBUM_ARTIST -> {
+ ApiRequestPager(
+ api,
+ createGetAlbumArtistsRequest(filter),
+ GetAlbumArtistsHandler,
+ viewModelScope,
+ useSeriesForPrimary = useSeriesForPrimary,
+ )
+ }
+ }
}
}
@@ -424,6 +495,17 @@ class CollectionFolderViewModel
)
}
+ private fun createGetAlbumArtistsRequest(filter: GetItemsFilter): GetAlbumArtistsRequest {
+ val item = state.value.item.successValue
+ return filter.applyTo(
+ GetAlbumArtistsRequest(
+ parentId = item?.id,
+ enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
+ fields = SlimItemFields,
+ ),
+ )
+ }
+
private fun createGetItemsRequest(
sortAndDirection: SortAndDirection,
recursive: Boolean,
@@ -495,18 +577,37 @@ class CollectionFolderViewModel
val filter = state.value.filter
when (filter.override) {
GetItemsFilterOverride.ARTIST -> {
- GetArtistsHandler.countMatching(
- api = api,
- request =
- createGetArtistsRequest(filter).copy(
- enableImageTypes = null,
- fields = null,
- nameLessThan = letter.toString(),
- limit = 0,
- enableTotalRecordCount = true,
- enableUserData = false,
- ),
- )
+ when (artistSource) {
+ ArtistSource.ARTIST -> {
+ GetArtistsHandler.countMatching(
+ api = api,
+ request =
+ createGetArtistsRequest(filter).copy(
+ enableImageTypes = null,
+ fields = null,
+ nameLessThan = letter.toString(),
+ limit = 0,
+ enableTotalRecordCount = true,
+ enableUserData = false,
+ ),
+ )
+ }
+
+ ArtistSource.ALBUM_ARTIST -> {
+ GetAlbumArtistsHandler.countMatching(
+ api = api,
+ request =
+ createGetAlbumArtistsRequest(filter).copy(
+ enableImageTypes = null,
+ fields = null,
+ nameLessThan = letter.toString(),
+ limit = 0,
+ enableTotalRecordCount = true,
+ enableUserData = false,
+ ),
+ )
+ }
+ }
}
// GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be
@@ -813,6 +914,33 @@ fun CollectionFolderViewContent(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
filterOptions: List> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null,
+ gridCardContent: @Composable CollectionFolderViewScope.(
+ GridItemDetails,
+ ) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) ->
+ GridCard(
+ item = item,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ imageContentScale = viewOptions.contentScale.scale,
+ imageAspectRatio = viewOptions.aspectRatio.ratio,
+ imageType = viewOptions.imageType,
+ showTitle = viewOptions.showTitles,
+ fillWidth = widthPx,
+ modifier = mod,
+ )
+ },
+ listItemContent: @Composable CollectionFolderViewScope.(
+ GridItemDetails,
+ ) -> Unit = { (item, index, onClick, onLongClick, _, mod) ->
+ CollectionFolderListItem(
+ item = item,
+ dense = viewOptions.type == ViewOptionsType.DENSE_LIST,
+ collectionType = collectionType,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = mod,
+ )
+ },
) {
var position by rememberInt(savedPosition)
@@ -975,11 +1103,22 @@ fun CollectionFolderViewContent(
focusRequester?.tryRequestFocus()
clickedRandom = false
}
+ val scope =
+ object : CollectionFolderViewScope {
+ override val viewOptions: ViewOptions
+ get() = state.viewOptions
+ override val collectionType: CollectionType?
+ get() = item?.data?.collectionType
+ override val sortAndDirection: SortAndDirection
+ get() = state.sortAndDirection
+ override val collectionSize: Int
+ get() = pager.data.size
+ }
+
Box(Modifier.fillMaxSize()) {
if (state.viewOptions.type == ViewOptionsType.GRID) {
- CollectionFolderGrid(
+ scope.CollectionFolderGrid(
preferences = preferences,
- collectionType = item?.data?.collectionType,
initialPosition = savedPosition,
items = pager.data,
sortAndDirection = state.sortAndDirection,
@@ -993,12 +1132,12 @@ fun CollectionFolderViewContent(
positionCallback?.invoke(columns, pos)
},
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
- viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem,
+ cardContent = gridCardContent,
)
} else {
- CollectionFolderList(
+ scope.CollectionFolderList(
preferences = preferences,
collectionType = item?.data?.collectionType,
initialPosition = savedPosition,
@@ -1014,9 +1153,9 @@ fun CollectionFolderViewContent(
positionCallback?.invoke(columns, pos)
},
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
- viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem,
+ itemContent = listItemContent,
)
}
androidx.compose.animation.AnimatedVisibility(
@@ -1047,6 +1186,7 @@ fun CollectionFolderViewContent(
ViewOptionsDialog(
viewOptions = state.viewOptions,
defaultViewOptions = defaultViewOptions,
+ showArtistSource = state.filter.override == GetItemsFilterOverride.ARTIST,
onDismissRequest = {
showViewOptions = false
viewActions.saveViewOptions(state.viewOptions)
@@ -1055,3 +1195,13 @@ fun CollectionFolderViewContent(
)
}
}
+
+/**
+ * Scope with global data for [CollectionFolderView], [CollectionFolderGrid], [CollectionFolderList], and individual items
+ */
+interface CollectionFolderViewScope {
+ val viewOptions: ViewOptions
+ val collectionType: CollectionType?
+ val sortAndDirection: SortAndDirection
+ val collectionSize: Int
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt
index 10a41af3..f87010d9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt
@@ -209,7 +209,12 @@ fun ContextMenu(
favorite = item.favorite,
seriesId = item.data.seriesId,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
- canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
+ canClearChosenStreams =
+ chosenStreams.let {
+ it?.itemPlayback != null ||
+ it?.audioStc?.isNotEmpty() == true ||
+ it?.subtitleStc?.isNotEmpty() == true
+ },
contextMenu = contextMenu,
actions = actions,
onChooseVersion = {
@@ -235,11 +240,11 @@ fun ContextMenu(
resources = resources,
streams = source.mediaStreams.orEmpty(),
type = type,
- currentIndex =
+ streamChoice =
if (type == MediaStreamType.AUDIO) {
- chosenStreams?.audioStream?.index
+ chosenStreams?.audioStream
} else {
- chosenStreams?.subtitleStream?.index
+ chosenStreams?.subtitleStream
},
onClick = { trackIndex ->
actions.onChooseTracks.invoke(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
index d7bd9951..76b7dc77 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt
@@ -64,6 +64,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
+import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@@ -230,28 +231,35 @@ fun DialogPopup(
onDismissRequest = onDismissRequest,
properties = properties,
) {
+ val elevatedContainerColor =
+ MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
DialogPopupContent(
title = title,
dialogItems = dialogItems,
waiting = waiting,
onDismissRequest = onDismissRequest,
dismissOnClick = dismissOnClick,
- elevation = elevation,
modifier =
- Modifier.onKeyEvent { event ->
- val code = event.nativeKeyEvent.keyCode
- if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
- code in
- setOf(
- KeyEvent.KEYCODE_ENTER,
- KeyEvent.KEYCODE_DPAD_CENTER,
- KeyEvent.KEYCODE_NUMPAD_ENTER,
- )
- ) {
- waiting = false
- }
- false
- },
+ Modifier
+ .onKeyEvent { event ->
+ val code = event.nativeKeyEvent.keyCode
+ if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
+ code in
+ setOf(
+ KeyEvent.KEYCODE_ENTER,
+ KeyEvent.KEYCODE_DPAD_CENTER,
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
+ )
+ ) {
+ waiting = false
+ }
+ false
+ }.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
+ .graphicsLayer {
+ this.clip = true
+ this.shape = RoundedCornerShape(28.0.dp)
+ }.drawBehind { drawRect(color = elevatedContainerColor) }
+ .padding(PaddingValues(24.dp)),
)
}
}
@@ -265,20 +273,10 @@ fun DialogPopupContent(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
dismissOnClick: Boolean = true,
- elevation: Dp = 8.dp,
) {
- val elevatedContainerColor =
- MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier =
- modifier
- .shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
- .graphicsLayer {
- this.clip = true
- this.shape = RoundedCornerShape(28.0.dp)
- }.drawBehind { drawRect(color = elevatedContainerColor) }
- .padding(PaddingValues(24.dp)),
+ modifier = modifier,
) {
Text(
text = title,
@@ -452,12 +450,14 @@ fun ConfirmDialog(
properties: DialogProperties = DialogProperties(),
elevation: Dp = 1.dp,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
+ @StringRes cancelStringRes: Int = R.string.cancel,
+ @StringRes confirmStringRes: Int = R.string.confirm,
) = BasicDialog(
onDismissRequest = onCancel,
properties = properties,
elevation = elevation,
content = {
- ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
+ ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes)
},
)
@@ -472,6 +472,8 @@ fun ConfirmDialogContent(
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
+ @StringRes cancelStringRes: Int = R.string.cancel,
+ @StringRes confirmStringRes: Int = R.string.confirm,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -502,11 +504,11 @@ fun ConfirmDialogContent(
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
- stringRes = R.string.cancel,
+ stringRes = cancelStringRes,
onClick = onCancel,
)
TextButton(
- stringRes = R.string.confirm,
+ stringRes = confirmStringRes,
onClick = onConfirm,
)
}
@@ -651,12 +653,13 @@ fun resourceFor(type: MediaStreamType): Int =
fun chooseStream(
resources: Resources,
streams: List,
- currentIndex: Int?,
+ streamChoice: StreamChoiceResult?,
type: MediaStreamType,
preferredSubtitleLanguage: String?,
onClick: (Int) -> Unit,
): DialogParams {
val filteredStreams = streams.filter { it.type == type }
+ val currentIndex = streamChoice?.stream?.index
return DialogParams(
fromLongClick = false,
title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))),
@@ -674,6 +677,16 @@ fun chooseStream(
},
supportingContent = {
},
+ trailingContent =
+ if (currentIndex == null && streamChoice != null) {
+ {
+ Text(
+ text = streamChoice.reason.description.getString(),
+ )
+ }
+ } else {
+ null
+ },
onClick = { onClick.invoke(TrackIndex.DISABLED) },
),
)
@@ -719,6 +732,16 @@ fun chooseStream(
supportingContent = {
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
},
+ trailingContent =
+ if (currentIndex == stream.index) {
+ {
+ Text(
+ text = streamChoice.reason.description.getString(),
+ )
+ }
+ } else {
+ null
+ },
onClick = { onClick.invoke(stream.index) },
)
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt
index 9ac5adc0..73e9af44 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt
@@ -60,7 +60,7 @@ private fun getRecommendedRows(parentId: UUID) =
),
),
RecommendedRow(
- title = R.string.top_unwatched,
+ title = R.string.top_rated,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
@@ -69,7 +69,6 @@ private fun getRecommendedRows(parentId: UUID) =
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,
enableUserData = true,
- isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING),
enableTotalRecordCount = false,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
index 9ff0e2c0..b69e1126 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt
@@ -97,6 +97,7 @@ class StudioViewModel
itemId = it.id,
imageType = ImageType.THUMB,
fillWidth = cardWidthPx,
+ tag = it.imageTags?.get(ImageType.THUMB),
)
Studio(
id = it.id,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
index c5da7565..4268f2d9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt
@@ -95,7 +95,11 @@ fun rememberLogoUrl(item: BaseItem?): String? {
item.data.seriesId != null &&
item.data.parentLogoImageTag != null
) {
- imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO)
+ imageUrlService.getItemImageUrl(
+ itemId = item.data.seriesId!!,
+ imageType = ImageType.LOGO,
+ tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId },
+ )
} else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) {
imageUrlService.getItemImageUrl(item, ImageType.LOGO)
} else {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt
index 083dded6..94842ef5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt
@@ -54,8 +54,8 @@ fun VideoStreamDetails(
) = VideoStreamDetails(
chosenStreams?.source,
chosenStreams?.videoStream,
- chosenStreams?.audioStream,
- chosenStreams?.subtitleStream,
+ chosenStreams?.audioStream?.stream,
+ chosenStreams?.subtitleStream?.stream,
numberOfVersions,
modifier,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
index a1237acd..df923a55 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt
@@ -55,6 +55,7 @@ fun ViewOptionsDialog(
onDismissRequest: () -> Unit,
onViewOptionsChange: (ViewOptions) -> Unit,
defaultViewOptions: ViewOptions = ViewOptions(),
+ showArtistSource: Boolean = false,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
@@ -72,11 +73,11 @@ fun ViewOptionsDialog(
window.setDimAmount(0f)
}
val options =
- remember(viewOptions.type) {
+ remember(viewOptions.type, showArtistSource) {
when (viewOptions.type) {
- ViewOptionsType.GRID -> ViewOptions.GRID_OPTIONS
- ViewOptionsType.LIST -> ViewOptions.LIST_OPTIONS
- ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS
+ ViewOptionsType.GRID -> ViewOptions.buildGridOptions(showArtistSource)
+ ViewOptionsType.LIST -> ViewOptions.buildListOptions(showArtistSource)
+ ViewOptionsType.DENSE_LIST -> ViewOptions.buildListOptions(showArtistSource)
}
}
Column(
@@ -130,6 +131,12 @@ fun ViewOptionsDialog(
}
}
+/** Which Jellyfin artists list backs the Music > Artists tab */
+enum class ArtistSource {
+ ARTIST,
+ ALBUM_ARTIST,
+}
+
/**
* Stores the customizable view changes from the user
*
@@ -146,6 +153,7 @@ data class ViewOptions(
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
val showTitles: Boolean = true,
val type: ViewOptionsType = ViewOptionsType.GRID,
+ val extras: ViewOptionsExtras = ViewOptionsExtras.None,
) {
companion object {
val ViewOptionsColumns =
@@ -250,12 +258,34 @@ data class ViewOptions(
valueToIndex = { it.ordinal },
)
+ val ViewOptionsArtistSource =
+ AppChoicePreference(
+ title = R.string.artist_source,
+ defaultValue = ArtistSource.ARTIST,
+ displayValues = R.array.artist_sources,
+ getter = { it.artistSource },
+ setter = { viewOptions, value ->
+ viewOptions.copy(extras = ViewOptionsExtras.MusicLibrary(value))
+ },
+ indexToValue = { ArtistSource.entries[it] },
+ valueToIndex = { it.ordinal },
+ )
+
val ViewOptionsReset =
AppClickablePreference(
title = R.string.reset,
)
- val GRID_OPTIONS =
+ /**
+ * Get the options for a grid [ViewOptions]
+ */
+ fun buildGridOptions(showArtistSource: Boolean) =
+ buildList {
+ if (showArtistSource) add(ViewOptionsArtistSource)
+ addAll(GRID_OPTIONS)
+ }
+
+ private val GRID_OPTIONS =
listOf(
ViewOptionsTypePref,
ViewOptionsImageType,
@@ -269,7 +299,16 @@ data class ViewOptions(
ViewOptionsReset,
)
- val LIST_OPTIONS =
+ /**
+ * Get the options for a list [ViewOptions]
+ */
+ fun buildListOptions(showArtistSource: Boolean) =
+ buildList {
+ if (showArtistSource) add(ViewOptionsArtistSource)
+ addAll(LIST_OPTIONS)
+ }
+
+ private val LIST_OPTIONS =
listOf(
ViewOptionsTypePref,
ViewOptionsDetailHeader,
@@ -338,3 +377,22 @@ val BaseItemKind.defaultViewOptions: ViewOptions
else -> ViewOptionsPoster
}
+
+/**
+ * Extras [ViewOptions] info that is context or library specific
+ */
+@Serializable
+sealed interface ViewOptionsExtras {
+ @Serializable
+ data object None : ViewOptionsExtras
+
+ @Serializable
+ data class MusicLibrary(
+ val artistSource: ArtistSource = ArtistSource.ARTIST,
+ ) : ViewOptionsExtras
+}
+
+/**
+ * The [ArtistSource] if the [ViewOptions.extras] is a [ViewOptionsExtras.MusicLibrary]
+ */
+val ViewOptions.artistSource: ArtistSource get() = (extras as? ViewOptionsExtras.MusicLibrary)?.artistSource ?: ArtistSource.ARTIST
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
index 7c8268b0..fb4cdbdd 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt
@@ -9,11 +9,16 @@ import android.media.AudioManager
import android.os.Build
import android.util.Log
import android.view.Display
+import android.view.Display.HdrCapabilities
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalGridApi
+import androidx.compose.foundation.layout.Grid
+import androidx.compose.foundation.layout.GridFlow
+import androidx.compose.foundation.layout.GridTrackSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -32,6 +37,7 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
@@ -40,9 +46,11 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.data.ItemPlaybackDao
+import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.ExceptionHandler
@@ -56,9 +64,11 @@ import kotlinx.coroutines.withContext
import org.acra.util.versionCodeLong
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
+import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.Date
+import java.util.Locale
import javax.inject.Inject
@HiltViewModel
@@ -68,17 +78,18 @@ class DebugViewModel
@param:ApplicationContext private val context: Context,
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
+ val seriesTrackChoiceDao: SeriesTrackChoiceDao,
val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo,
+ private val deviceProfileService: DeviceProfileService,
) : ViewModel() {
val state = MutableStateFlow(DebugState())
val itemPlaybacks = MutableStateFlow>(emptyList())
val logcat = MutableStateFlow>(emptyList())
- val supportedModes by lazy {
+ val display: Display by lazy {
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
- val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
- display.supportedModes.orEmpty()
+ displayManager.getDisplay(Display.DEFAULT_DISPLAY)
}
val av1Included by lazy {
@@ -159,7 +170,7 @@ class DebugViewModel
state.update {
it.copy(
deviceInfo = deviceInfoList,
- displayModes = supportedModes.map { it.toString() },
+ displayModes = display.supportedModes.map { it.toDisplayMode(display) },
)
}
}
@@ -169,6 +180,13 @@ class DebugViewModel
itemPlaybacks.value = results
}
}
+ viewModelScope.launchIO {
+ serverRepository.currentUser?.rowId?.let {
+ val results = seriesTrackChoiceDao.getAll(it)
+ Timber.v("Got %s SeriesTrackChoice", results.size)
+ results.forEach { Timber.v("%s", it) }
+ }
+ }
viewModelScope.launchIO {
val logcat = getLogCatLines()
this@DebugViewModel.logcat.value = logcat
@@ -206,6 +224,20 @@ class DebugViewModel
audioManager.registerAudioDeviceCallback(callback, null)
addCloseable { audioManager.unregisterAudioDeviceCallback(callback) }
}
+ viewModelScope.launchIO {
+ val hdrSupport =
+ buildMap {
+ put("HEVC HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10())
+ put("HEVC HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10Plus())
+ put("DoVi single layer", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVision())
+ put("DoVi Profile 7 EL", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionEL())
+ put("DoVi Profile 8", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionProfile8())
+ put("AV1 HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10())
+ put("AV1 HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10Plus())
+ put("AV1 DoVi", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1DolbyVision())
+ }
+ state.update { it.copy(hdrSupport = hdrSupport) }
+ }
}
companion object {
@@ -262,8 +294,9 @@ data class LogcatLine(
data class DebugState(
val appInfo: List = emptyList(),
val deviceInfo: List = emptyList(),
- val displayModes: List = emptyList(),
+ val displayModes: List = emptyList(),
val audioInfo: List = emptyList(),
+ val hdrSupport: Map = emptyMap(),
)
val AudioDeviceInfo.details: String
@@ -383,6 +416,7 @@ val AudioDeviceInfo.details: String
"productName=$productName, address=$addressStr)"
}
+@OptIn(ExperimentalGridApi::class)
@Composable
fun DebugPage(
preferences: UserPreferences,
@@ -466,15 +500,54 @@ fun DebugPage(
BodyText(it)
}
- SubSectionTitle("Display Modes")
- state.displayModes.forEach {
- BodyText(it)
- }
-
SubSectionTitle("Audio Devices")
state.audioInfo.forEach {
BodyText(it)
}
+
+ SubSectionTitle("Display Modes")
+ Grid(
+ config = {
+ repeat(6) { column(GridTrackSize.Auto) }
+ flow = GridFlow.Row
+ columnGap(8.dp)
+ rowGap(8.dp)
+ },
+ ) {
+ EmphasizedBodyText("ID")
+ EmphasizedBodyText("Width")
+ EmphasizedBodyText("Height")
+ EmphasizedBodyText("FPS")
+ EmphasizedBodyText("HDR")
+ EmphasizedBodyText("Alt FPS")
+
+ state.displayModes.forEachIndexed { index, mode ->
+ BodyText(mode.id.toString())
+ BodyText(mode.width.toString())
+ BodyText(mode.height.toString())
+ BodyText(mode.fps)
+ BodyText(mode.hdr)
+ BodyText(mode.altFps)
+ }
+ }
+
+ SubSectionTitle("Detected HDR Support")
+ Grid(
+ config = {
+ column(GridTrackSize.Auto)
+ column(GridTrackSize.Auto)
+ flow = GridFlow.Row
+ columnGap(8.dp)
+ rowGap(4.dp)
+ },
+ ) {
+ EmphasizedBodyText("HDR Format")
+ EmphasizedBodyText("Supported")
+ state.hdrSupport.forEach { (name, supported) ->
+ BodyText(name)
+ BodyText(supported.toString())
+ }
+ }
}
}
item {
@@ -565,11 +638,29 @@ private fun SubSectionTitle(text: String) {
}
@Composable
-private fun BodyText(text: String) {
+private fun BodyText(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun EmphasizedBodyText(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ modifier = modifier,
)
}
@@ -601,3 +692,45 @@ private val THIRD_PARTY_TAGS =
"HardwareConfig:W",
"DfltImageHeaderParser:W",
)
+
+data class DisplayMode(
+ val id: Int,
+ val width: Int,
+ val height: Int,
+ val fps: String,
+ val hdr: String,
+ val altFps: String,
+)
+
+fun Display.Mode.toDisplayMode(display: Display): DisplayMode {
+ val fps = String.format(Locale.getDefault(), "%.3f", refreshRate)
+ val altFps =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ alternativeRefreshRates.joinToString(", ") {
+ String.format(Locale.getDefault(), "%.3f", it)
+ }
+ } else {
+ "N/A"
+ }
+ val hdr =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ supportedHdrTypes.joinToString(", ") { hdrTypeToString(it) }
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ display.hdrCapabilities?.supportedHdrTypes?.joinToString(", ") { hdrTypeToString(it) }
+ ?: "None found"
+ } else {
+ "N/A"
+ }
+ return DisplayMode(modeId, physicalWidth, physicalHeight, fps, hdr, altFps)
+}
+
+fun hdrTypeToString(type: Int): String =
+ when (type) {
+ HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10"
+ HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10+"
+ HdrCapabilities.HDR_TYPE_HLG -> "HLG"
+ HdrCapabilities.HDR_TYPE_HLG_PLUS -> "HLG+"
+ HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DoVi"
+ HdrCapabilities.HDR_TYPE_INVALID -> "Invalid"
+ else -> "Unknown ($type)"
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt
index adbd9a44..5690fadc 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt
@@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.showToast
+import com.github.damontecres.wholphin.ui.util.goToRandom
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.BlockingList
import com.github.damontecres.wholphin.util.DataLoadingState
@@ -700,11 +701,25 @@ class FavoritesViewModel
viewModelScope.launchIO {
try {
collectionStateFor(type)?.let { collectionState ->
- val random =
- (collectionState.items.successValue as? BlockingList)?.randomBlocking()
- Timber.d("Got random item: %s", random?.id)
- random?.destination()?.let {
- navigateTo(random.destination())
+ val list =
+ collectionState.items.successValue as? BlockingList
+ if (list != null) {
+ goToRandom(
+ navigateTo = ::navigateTo,
+ parentId = libraryDisplayItemId(type),
+ filter =
+ collectionState.filter.copy(
+ includeItemTypes = listOf(type),
+ ),
+ sortAndDirection = collectionState.sortAndDirection,
+ recursive = true,
+ list = list,
+ )
+ } else {
+ Timber.w(
+ "state.value.items is not successful: %s",
+ collectionState.items,
+ )
}
}
} catch (ex: CancellationException) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt
index 1fd7ea9f..4e49bd39 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt
@@ -24,7 +24,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
-import com.github.damontecres.wholphin.services.HomeSettingsService
+import com.github.damontecres.wholphin.services.HomeDataService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavDrawerService
@@ -34,7 +34,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
-import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@@ -75,7 +74,7 @@ class HomeRowGridViewModel
val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService,
- private val homeSettingsService: HomeSettingsService,
+ private val homeDataService: HomeDataService,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
private val navigationManager: NavigationManager,
@@ -128,7 +127,7 @@ class HomeRowGridViewModel
val libraries =
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val result =
- homeSettingsService.fetchDataForRow(
+ homeDataService.fetchDataForRow(
row = rowConfig,
scope = viewModelScope,
prefs = prefs,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
index 27212b5e..64c48f0e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt
@@ -257,6 +257,7 @@ fun PersonPage(
imageUrlService.getItemImageUrl(
itemId = person.id,
imageType = ImageType.PRIMARY,
+ tag = person.data.imageTags?.get(ImageType.PRIMARY),
)
}
// A long filmography is hard to navigate as a row, so it is cut at the same
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
index b7085db6..33960f83 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt
@@ -1,49 +1,26 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
-import androidx.compose.foundation.background
-import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.Immutable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusProperties
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.focus.focusRestorer
-import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
@@ -51,60 +28,49 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
-import androidx.tv.material3.ListItem
+import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
-import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
-import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
-import com.github.damontecres.wholphin.data.filter.DefaultPlaylistItemsOptions
-import com.github.damontecres.wholphin.data.filter.FilterValueOption
-import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.BaseItem
-import com.github.damontecres.wholphin.data.model.GetItemsFilter
-import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
+import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
-import com.github.damontecres.wholphin.services.BackdropService
-import com.github.damontecres.wholphin.services.FavoriteWatchManager
-import com.github.damontecres.wholphin.services.FilterOptionCache
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MusicService
-import com.github.damontecres.wholphin.services.MusicServiceState
import com.github.damontecres.wholphin.services.NavigationManager
-import com.github.damontecres.wholphin.services.PlaylistCreator
-import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.ui.FontAwesome
-import com.github.damontecres.wholphin.ui.SlimItemFields
-import com.github.damontecres.wholphin.ui.cards.ItemCardImage
+import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator
+import com.github.damontecres.wholphin.ui.cards.WatchedIcon
+import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.Button
+import com.github.damontecres.wholphin.ui.components.CollectionFolderState
+import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent
+import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
+import com.github.damontecres.wholphin.ui.components.CollectionFolderViewScope
import com.github.damontecres.wholphin.ui.components.ContextMenu
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
import com.github.damontecres.wholphin.ui.components.ContextMenuDialog
-import com.github.damontecres.wholphin.ui.components.ErrorMessage
-import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
-import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
-import com.github.damontecres.wholphin.ui.components.FilterByButton
-import com.github.damontecres.wholphin.ui.components.GridTitle
-import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.components.GridClickActions
+import com.github.damontecres.wholphin.ui.components.ListItemWrapper
import com.github.damontecres.wholphin.ui.components.MusicContextActions
import com.github.damontecres.wholphin.ui.components.Optional
-import com.github.damontecres.wholphin.ui.components.OverviewText
-import com.github.damontecres.wholphin.ui.components.SortByButton
import com.github.damontecres.wholphin.ui.components.TextButton
+import com.github.damontecres.wholphin.ui.components.ViewOptions
+import com.github.damontecres.wholphin.ui.components.ViewOptionsType
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
-import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
+import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker
import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.equalsNotNull
-import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.formatTime
-import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.settings.MoveDirection
@@ -113,13 +79,9 @@ import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.roundSeconds
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
-import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.util.ApiRequestPager
-import com.github.damontecres.wholphin.util.ExceptionHandler
-import com.github.damontecres.wholphin.util.GetItemsRequestHandler
-import com.github.damontecres.wholphin.util.LoadingState
-import com.github.damontecres.wholphin.util.WholphinDispatchers
+import com.github.damontecres.wholphin.util.successValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -127,62 +89,62 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.playlistsApi
-import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder
-import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
-import kotlin.time.Duration
@HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class)
class PlaylistViewModel
@AssistedInject
constructor(
+ private val savedStateHandle: SavedStateHandle,
@ApplicationContext context: Context,
api: ApiClient,
navigationManager: NavigationManager,
musicService: MusicService,
mediaManagementService: MediaManagementService,
- private val backdropService: BackdropService,
private val serverRepository: ServerRepository,
- private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
- private val favoriteWatchManager: FavoriteWatchManager,
- private val serverReportService: ServerReportService,
- private val filterOptionCache: FilterOptionCache,
- private val playlistCreator: PlaylistCreator,
@Assisted itemId: UUID,
+ @Assisted private val collectionState: StateFlow,
) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) {
@AssistedFactory
interface Factory {
- fun create(itemId: UUID): PlaylistViewModel
+ fun create(
+ itemId: UUID,
+ collectionState: StateFlow,
+ ): PlaylistViewModel
}
- val state = MutableStateFlow(PlaylistDetailsState())
+ private val _state = MutableStateFlow(PlaylistDetailsState())
+ val state: StateFlow = _state
+
val musicState = musicService.state
+ var position: Int
+ get() = savedStateHandle.get("position") ?: 0
+ set(value) {
+ savedStateHandle["position"] = value
+ }
+
init {
init()
}
override fun init() {
- state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchDefault {
try {
- val playlist =
- api.userLibraryApi
- .getItem(itemId)
- .content
- .let { BaseItem(it, false) }
val user = serverRepository.currentUser
val canEdit =
user?.let { user ->
@@ -215,95 +177,12 @@ class PlaylistViewModel
false
}
} ?: false
- state.update {
- it.copy(
- playlist = playlist,
- canEdit = canEdit,
- )
- }
- val libraryDisplayInfo =
- user?.let { user ->
- libraryDisplayInfoDao.getItem(user, itemId)
- }
- val filter = libraryDisplayInfo?.filter ?: GetItemsFilter()
- val sortAndDirection =
- libraryDisplayInfo?.sortAndDirection ?: SortAndDirection(
- ItemSortBy.DEFAULT,
- SortOrder.ASCENDING,
- )
- loadItems(filter, sortAndDirection).join()
+ _state.update { it.copy(canEdit = canEdit) }
determineMediaType()
} catch (ex: Exception) {
- Timber.e(ex, "Error fetching playlist %s", itemId)
- state.update { it.copy(loading = LoadingState.Error(ex)) }
- }
- }
- }
-
- fun loadItems(
- filter: GetItemsFilter,
- sortAndDirection: SortAndDirection,
- ) = viewModelScope.launchIO {
- backdropService.clearBackdrop()
- state.update {
- it.copy(
- loading = LoadingState.Loading,
- filterAndSort = FilterAndSort(filter, sortAndDirection),
- )
- }
-
- serverRepository.currentUser?.let { user ->
- viewModelScope.launchIO {
- val libraryDisplayInfo =
- libraryDisplayInfoDao.getItem(user, itemId)?.copy(
- filter = filter,
- sort = sortAndDirection.sort,
- direction = sortAndDirection.direction,
- )
- ?: LibraryDisplayInfo(
- userId = user.rowId,
- itemId = itemId.toServerString(),
- sort = sortAndDirection.sort,
- direction = sortAndDirection.direction,
- filter = filter,
- viewOptions = null,
- )
- libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
- }
-
- val request =
- filter.applyTo(
- GetItemsRequest(
- parentId = itemId,
- userId = user.id,
- fields = SlimItemFields,
- sortBy = listOf(sortAndDirection.sort),
- sortOrder = listOf(sortAndDirection.direction),
- ),
- )
- try {
- val pager =
- ApiRequestPager(
- api,
- request,
- GetItemsRequestHandler,
- viewModelScope,
- ).init()
- state.update {
- it.copy(
- items = pager,
- loading = LoadingState.Success,
- )
- }
- } catch (ex: Exception) {
- Timber.e(ex, "Error fetching playlist %s", itemId)
- state.update {
- it.copy(
- items = emptyList(),
- loading = LoadingState.Error(ex),
- )
- }
+ Timber.e(ex, "Error fetching playlist info for %s", itemId)
+// _state.update { it.copy(item = DataLoadingState.Error(ex)) }
}
}
}
@@ -313,77 +192,49 @@ class PlaylistViewModel
*
* In theory, the server will set the type, but sometimes it doesn't
*/
- private suspend fun determineMediaType() {
- // Use the type the server says
- var mediaType =
- state.value.playlist
- ?.data
- ?.mediaType ?: MediaType.UNKNOWN
- mediaType =
- if (mediaType == MediaType.UNKNOWN) {
- // Otherwise, if a most of the list is one type, we can assume that type
- val pager = (state.value.items as? ApiRequestPager<*>)
- if (pager != null && pager.size <= 50) {
- val types =
- (0..<50.coerceAtMost(pager.size)).groupBy { index ->
- val pagerItem = pager.getBlocking(index)
- when (pagerItem?.type) {
- BaseItemKind.AUDIO -> MediaType.AUDIO
+ private fun determineMediaType() {
+ collectionState
+ .map { Pair(it.item, it.items) }
+ .distinctUntilChanged()
+ .catch { ex -> Timber.e(ex, "Error in determineMediaType") }
+ .collectLatestIn(viewModelScope) { (item, items) ->
+ var mediaType = item.successValue?.data?.mediaType ?: MediaType.UNKNOWN
+ mediaType =
+ if (mediaType == MediaType.UNKNOWN) {
+ // Otherwise, if a most of the list is one type, we can assume that type
+ val pager = (items.successValue as? ApiRequestPager<*>)
+ if (pager != null && pager.size <= 50) {
+ val types =
+ (0..<50.coerceAtMost(pager.size)).groupBy { index ->
+ val pagerItem = pager.getBlocking(index)
+ when (pagerItem?.type) {
+ BaseItemKind.AUDIO -> MediaType.AUDIO
- BaseItemKind.VIDEO,
- BaseItemKind.EPISODE,
- BaseItemKind.MOVIE,
- BaseItemKind.BOX_SET,
- -> MediaType.VIDEO
+ BaseItemKind.VIDEO,
+ BaseItemKind.EPISODE,
+ BaseItemKind.MOVIE,
+ BaseItemKind.BOX_SET,
+ -> MediaType.VIDEO
- else -> MediaType.UNKNOWN
+ else -> MediaType.UNKNOWN
+ }
+ }
+ if (types.keys.size == 1) {
+ types.keys.first()
+ } else {
+ MediaType.UNKNOWN
}
+ } else {
+ MediaType.UNKNOWN
}
- if (types.keys.size == 1) {
- types.keys.first()
} else {
- MediaType.UNKNOWN
+ mediaType
}
- } else {
- MediaType.UNKNOWN
+ Timber.d("mediaType=%s", mediaType)
+ _state.update {
+ it.copy(mediaType = mediaType)
}
- } else {
- mediaType
}
- Timber.d("mediaType=%s", mediaType)
- state.update {
- it.copy(mediaType = mediaType)
- }
- }
-
- suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List =
- filterOptionCache.getFilterOptionValues(
- itemId,
- filterOption,
- )
-
- fun updateBackdrop(item: BaseItem) {
- viewModelScope.launchIO {
- backdropService.submit(item)
- }
- }
-
- fun setWatched(
- itemId: UUID,
- played: Boolean,
- ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
- favoriteWatchManager.setWatched(itemId, played)
- }
-
- fun setFavorite(
- itemId: UUID,
- favorite: Boolean,
- ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
- favoriteWatchManager.setFavorite(itemId, favorite)
- }
-
- fun sendMediaReport(itemId: UUID) {
- viewModelScope.launchDefault { serverReportService.sendMediaReportFor(itemId) }
}
fun removeFromPlaylist(
@@ -392,11 +243,12 @@ class PlaylistViewModel
) {
viewModelScope.launchIO {
try {
- playlistCreator.removeFromServerPlaylist(
- playlistId = this@PlaylistViewModel.itemId,
- itemId = itemId,
+ api.playlistsApi.removeItemFromPlaylist(
+ this@PlaylistViewModel.itemId.toServerString(),
+ listOf(itemId.toServerString()),
)
- (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index)
+ (collectionState.value.items.successValue as? ApiRequestPager<*>)
+ ?.refreshPagesAfter(index)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
@@ -423,31 +275,15 @@ class PlaylistViewModel
itemId = itemId.toServerString(),
newIndex = newIndex,
)
- (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index - 1)
+ (collectionState.value.items.successValue as? ApiRequestPager<*>)?.refreshPagesAfter(
+ index - 1,
+ )
}
}
}
-@Immutable
-data class FilterAndSort(
- val filter: GetItemsFilter,
- val sortAndDirection: SortAndDirection,
-)
-
data class PlaylistDetailsState(
- val playlist: BaseItem? = null,
val mediaType: MediaType = MediaType.UNKNOWN,
- val items: List = emptyList(),
- val filterAndSort: FilterAndSort =
- FilterAndSort(
- filter = GetItemsFilter(),
- sortAndDirection =
- SortAndDirection(
- ItemSortBy.DEFAULT,
- SortOrder.ASCENDING,
- ),
- ),
- val loading: LoadingState = LoadingState.Pending,
val canEdit: Boolean = false,
)
@@ -456,14 +292,33 @@ fun PlaylistDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
+ collectionViewModel: CollectionFolderViewModel =
+ hiltViewModel(
+ creationCallback = {
+ it.create(
+ itemId = destination.itemId.toServerString(),
+ initialSortAndDirection =
+ SortAndDirection(
+ ItemSortBy.DEFAULT,
+ SortOrder.ASCENDING,
+ ),
+ recursive = false,
+ collectionFilter = CollectionFolderFilter(),
+ useSeriesForPrimary = false,
+ defaultViewOptions = playlistDefaultViewOptions,
+ )
+ },
+ ),
viewModel: PlaylistViewModel =
hiltViewModel(
- creationCallback = { it.create(destination.itemId) },
+ creationCallback = { it.create(destination.itemId, collectionViewModel.state) },
),
addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
- val state by viewModel.state.collectAsState()
+ val state by collectionViewModel.state.collectAsState()
+ val playlistState by viewModel.state.collectAsState()
val musicState by viewModel.musicState.collectAsState()
+ val focusManager = LocalFocusManager.current
var showContextMenu by remember { mutableStateOf(null) }
var showConfirmTypeDialog by remember { mutableStateOf?>(null) }
@@ -476,15 +331,15 @@ fun PlaylistDetails(
shuffle: Boolean,
mediaTypeOverride: MediaType? = null,
) {
- when (mediaTypeOverride ?: state.mediaType) {
+ when (mediaTypeOverride ?: playlistState.mediaType) {
MediaType.VIDEO -> {
- viewModel.navigationManager.navigateTo(
+ collectionViewModel.navigateTo(
Destination.PlaybackList(
itemId = destination.itemId,
startIndex = index,
shuffle = shuffle,
- filter = state.filterAndSort.filter,
- sortAndDirection = state.filterAndSort.sortAndDirection,
+ filter = state.filter,
+ sortAndDirection = state.sortAndDirection,
),
)
}
@@ -498,93 +353,152 @@ fun PlaylistDetails(
}
}
}
- val musicContextActions =
- remember {
- MusicContextActions(
- navigateTo = { viewModel.navigationManager.navigateTo(it) },
- onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) },
- onClickPlayNext = { index, item -> viewModel.playNext(item) },
- onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) },
- onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
- onClickAddPlaylist = { itemId ->
- addToPlaylistViewModel.loadPlaylists()
- showPlaylistDialog.makePresent(itemId)
- },
- onClickRemoveFromQueue = { _, _ -> },
- onDeleteItem = viewModel::deleteItem,
- onRemoveFromPlaylist = viewModel::removeFromPlaylist,
- )
- }
- val contextActions =
- remember {
- ContextMenuActions(
- navigateTo = { viewModel.navigationManager.navigateTo(it) },
- onClickWatch = { id, watched -> viewModel.setWatched(id, watched) },
- onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) },
- onClickAddPlaylist = { itemId ->
- addToPlaylistViewModel.loadPlaylists()
- showPlaylistDialog.makePresent(itemId)
- },
- onSendMediaInfo = viewModel::sendMediaReport,
- onDeleteItem = viewModel::deleteItem,
- onClickAddToQueue = { viewModel.addToQueue(it, 0) },
- onShowOverview = {},
- onChooseVersion = { _, _ -> },
- onChooseTracks = {},
- onClearChosenStreams = {},
- onClickRemoveFromNextUp = {},
- onRemoveFromPlaylist = viewModel::removeFromPlaylist,
- )
- }
- PlaylistDetailsContent(
- loadingState = state.loading,
- playlist = state.playlist,
- items = state.items,
- musicState = musicState,
- onChangeBackdrop = viewModel::updateBackdrop,
- onClickIndex = { index, item ->
- play(index, item, false)
- },
- onClickPlay = { shuffle ->
- state.playlist?.let {
- play(0, it, shuffle)
+ fun showContextMenu(
+ index: Int,
+ item: BaseItem,
+ fromLongClick: Boolean,
+ ) {
+ showContextMenu =
+ if (item.type == BaseItemKind.AUDIO) {
+ val musicContextActions =
+ MusicContextActions(
+ navigateTo = { collectionViewModel.navigateTo(it) },
+ onClickPlay = { index, item ->
+ play(
+ index,
+ item,
+ false,
+ MediaType.AUDIO,
+ )
+ },
+ onClickPlayNext = { index, item -> viewModel.playNext(item) },
+ onClickAddToQueue = { item ->
+ viewModel.addToQueue(
+ item,
+ Int.MAX_VALUE,
+ )
+ },
+ onClickFavorite = { id, favorite ->
+ collectionViewModel.setFavorite(index, id, favorite)
+ },
+ onClickAddPlaylist = { itemId ->
+ addToPlaylistViewModel.loadPlaylists()
+ showPlaylistDialog.makePresent(itemId)
+ },
+ onClickRemoveFromQueue = { _, _ -> },
+ onDeleteItem = { collectionViewModel.deleteItem(index, item) },
+ onRemoveFromPlaylist = viewModel::removeFromPlaylist,
+ )
+ ContextMenu.ForMusic(
+ fromLongClick = fromLongClick,
+ item = item,
+ index = index,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
+ canRemoveFromQueue = false,
+ actions = musicContextActions,
+ showRemoveFromPlaylist = playlistState.canEdit,
+ )
+ } else {
+ val contextActions =
+ ContextMenuActions(
+ navigateTo = { collectionViewModel.navigateTo(it) },
+ onClickWatch = { id, watched ->
+ collectionViewModel.setWatched(
+ index,
+ id,
+ watched,
+ )
+ },
+ onClickFavorite = { id, favorite ->
+ collectionViewModel.setFavorite(index, id, favorite)
+ },
+ onClickAddPlaylist = { itemId ->
+ addToPlaylistViewModel.loadPlaylists()
+ showPlaylistDialog.makePresent(itemId)
+ },
+ onSendMediaInfo = collectionViewModel::sendReportFor,
+ onDeleteItem = { collectionViewModel.deleteItem(index, item) },
+ onClickAddToQueue = { viewModel.addToQueue(it, 0) },
+ onShowOverview = {},
+ onChooseVersion = { _, _ -> },
+ onChooseTracks = {},
+ onClearChosenStreams = {},
+ onClickRemoveFromNextUp = {},
+ onRemoveFromPlaylist = viewModel::removeFromPlaylist,
+ )
+ ContextMenu.ForBaseItem(
+ fromLongClick = fromLongClick,
+ item = item,
+ index = index,
+ chosenStreams = null,
+ showGoTo = true,
+ showStreamChoices = false,
+ canDelete = viewModel.canDelete(item, preferences.appPreferences),
+ canRemoveContinueWatching = false,
+ canRemoveNextUp = false,
+ actions = contextActions,
+ showRemoveFromPlaylist = playlistState.canEdit,
+ )
}
- },
- onShowContextMenu = { index, item, fromLongClick ->
- showContextMenu =
- if (item.type == BaseItemKind.AUDIO) {
- ContextMenu.ForMusic(
- fromLongClick = fromLongClick,
- item = item,
- index = index,
- canDelete = viewModel.canDelete(item, preferences.appPreferences),
- canRemoveFromQueue = false,
- actions = musicContextActions,
- showRemoveFromPlaylist = state.canEdit,
- )
- } else {
- ContextMenu.ForBaseItem(
- fromLongClick = fromLongClick,
- item = item,
- index = index,
- chosenStreams = null,
- showGoTo = true,
- showStreamChoices = false,
- canDelete = viewModel.canDelete(item, preferences.appPreferences),
- canRemoveContinueWatching = false,
- canRemoveNextUp = false,
- actions = contextActions,
- showRemoveFromPlaylist = state.canEdit,
- )
- }
- },
- filterAndSort = state.filterAndSort,
- onFilterAndSortChange = viewModel::loadItems,
- getPossibleFilterValues = viewModel::getFilterOptionValues,
- canEdit = state.canEdit,
- onMoveItem = viewModel::onMoveItem,
+ }
+
+ val actions =
+ remember {
+ GridClickActions(
+ onClickItem = { index, item -> collectionViewModel.navigateTo(item.destination(index)) },
+ onLongClickItem = { index, item -> showContextMenu(index, item, true) },
+ onClickPlayAll = { shuffle ->
+ state.item.successValue?.let { playlist -> play(0, playlist, shuffle) }
+ },
+ onClickPlayRemoteButton = { index, item -> play(index, item, false) },
+ )
+ }
+ CollectionFolderViewContent(
+ preferences = preferences,
+ state = state,
+ savedPosition = viewModel.position,
+ itemId = destination.itemId.toServerString(),
+ initialFilter = CollectionFolderFilter(filter = state.filter),
+ recursive = false,
+ actions = actions,
+ sortOptions = PlaylistSortOptions,
+ playEnabled = true,
+ defaultViewOptions = playlistDefaultViewOptions,
+ viewActions = collectionViewModel,
+ provider = collectionViewModel,
+ positionCallback = { _, position -> viewModel.position = position },
modifier = modifier,
+ listItemContent = { (item, index, onClick, onLongClick, _, mod) ->
+ PlaylistItem(
+ item = item,
+ index = index,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ canMove = playlistState.canEdit,
+ moveUpAllowed = index > 0,
+ moveDownAllowed =
+ state.items.successValue
+ ?.lastIndex
+ ?.gt(index) ?: false,
+ onClickMove = { direction ->
+ item?.let { viewModel.onMoveItem(index, item.id, direction) }
+ when (direction) {
+ MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up)
+ MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down)
+ }
+ },
+ onClickMore = { item?.let { showContextMenu(index, item, false) } },
+ modifier = mod,
+// interactionSource = TODO(),
+ isPlaying =
+ equalsNotNull(
+ musicState.currentItemId,
+ item?.id,
+ ),
+ isQueued = remember(musicState.queueVersion) { item?.id in musicState.queuedIds },
+ )
+ },
)
showContextMenu?.let { contextMenu ->
ContextMenuDialog(
@@ -621,294 +535,7 @@ fun PlaylistDetails(
}
@Composable
-fun PlaylistDetailsContent(
- playlist: BaseItem?,
- items: List,
- musicState: MusicServiceState,
- onClickIndex: (Int, BaseItem) -> Unit,
- onShowContextMenu: (Int, BaseItem, Boolean) -> Unit,
- onClickPlay: (shuffle: Boolean) -> Unit,
- onChangeBackdrop: (BaseItem) -> Unit,
- onMoveItem: (Int, UUID, MoveDirection) -> Unit,
- filterAndSort: FilterAndSort,
- onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
- getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List,
- loadingState: LoadingState,
- canEdit: Boolean,
- modifier: Modifier = Modifier,
-) {
- var savedIndex by rememberSaveable { mutableIntStateOf(0) }
- var focusedIndex by remember { mutableIntStateOf(savedIndex) }
- val focusedItem = items.getOrNull(focusedIndex)
- LaunchedEffect(focusedItem) {
- focusedItem?.let(onChangeBackdrop)
- }
- val focusRequester = remember { FocusRequester() }
- LaunchedEffect(loadingState) {
- if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) {
- focusRequester.tryRequestFocus()
- }
- }
-
- val playButtonFocusRequester = remember { FocusRequester() }
-
- Box(
- modifier = modifier,
- ) {
- Column(
- verticalArrangement = Arrangement.spacedBy(16.dp),
- modifier =
- Modifier
- .fillMaxSize(),
- ) {
- val title =
- if (loadingState is LoadingState.Success) {
- playlist?.name ?: stringResource(R.string.playlist)
- } else {
- ""
- }
- GridTitle(title)
- Row(
- horizontalArrangement = Arrangement.spacedBy(24.dp),
- modifier =
- Modifier
- .fillMaxWidth(),
- ) {
- PlaylistDetailsHeader(
- focusedItem = focusedItem,
- onClickPlay = onClickPlay,
- playButtonFocusRequester = playButtonFocusRequester,
- focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() },
- filterAndSort = filterAndSort,
- onFilterAndSortChange = onFilterAndSortChange,
- getPossibleFilterValues = getPossibleFilterValues,
- filterOptions = DefaultPlaylistItemsOptions,
- modifier =
- Modifier
- .padding(top = 80.dp)
- .fillMaxWidth(.25f),
- )
- val filterCount =
- remember(filterAndSort) {
- filterAndSort.filter.countFilters(DefaultPlaylistItemsOptions)
- }
- PlaylistItems(
- loadingState = loadingState,
- items = items,
- musicState = musicState,
- playButtonFocusRequester = playButtonFocusRequester,
- onFocusItem = { index, item ->
- focusedIndex = index
- },
- onClickItem = { index, item ->
- savedIndex = index
- item?.let { onClickIndex.invoke(index, item) }
- },
- onShowContextMenu = { index, item, fromLongClick ->
- savedIndex = index
- item?.let { onShowContextMenu.invoke(index, item, fromLongClick) }
- },
- canMove = canEdit && filterAndSort.sortAndDirection.sort == ItemSortBy.DEFAULT && filterCount == 0,
- onMoveItem = onMoveItem,
- modifier =
- Modifier
- .padding(start = 16.dp)
- .weight(1f)
- .focusRequester(focusRequester)
- .focusProperties {
- onExit = {
- playButtonFocusRequester.tryRequestFocus()
- }
- },
- )
- }
- }
- }
-}
-
-@Composable
-fun PlaylistDetailsHeader(
- focusedItem: BaseItem?,
- filterOptions: List>,
- onClickPlay: (shuffle: Boolean) -> Unit,
- playButtonFocusRequester: FocusRequester,
- focusRequester: FocusRequester,
- filterAndSort: FilterAndSort,
- onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit,
- getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List,
- modifier: Modifier = Modifier,
-) {
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = modifier,
- ) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier,
- ) {
- ExpandablePlayButton(
- title = R.string.play,
- resume = Duration.ZERO,
- icon = Icons.Default.PlayArrow,
- onClick = { onClickPlay.invoke(false) },
- modifier = Modifier.focusRequester(playButtonFocusRequester),
- )
- ExpandableFaButton(
- title = R.string.shuffle,
- iconStringRes = R.string.fa_shuffle,
- onClick = { onClickPlay.invoke(true) },
- )
- }
- Row(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier,
- ) {
- FilterByButton(
- filterOptions = filterOptions,
- current = filterAndSort.filter,
- onFilterChange = {
- onFilterAndSortChange.invoke(
- it,
- filterAndSort.sortAndDirection,
- )
- },
- getPossibleValues = getPossibleFilterValues,
- modifier = Modifier.focusRequester(focusRequester),
- )
- SortByButton(
- sortOptions = BoxSetSortOptions,
- current = filterAndSort.sortAndDirection,
- onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) },
- )
- }
- Text(
- text = focusedItem?.title ?: "",
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.headlineSmall,
- )
- Text(
- text = focusedItem?.subtitle ?: "",
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.titleMedium,
- )
- if (focusedItem?.type == BaseItemKind.EPISODE && focusedItem.data.premiereDate != null) {
- Text(
- text = formatDateTime(focusedItem.data.premiereDate!!),
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.titleSmall,
- )
- }
- OverviewText(
- overview = focusedItem?.data?.overview ?: "",
- maxLines = 10,
- onClick = {},
- enabled = false,
- )
- }
-}
-
-@Composable
-fun PlaylistItems(
- loadingState: LoadingState,
- items: List,
- musicState: MusicServiceState,
- playButtonFocusRequester: FocusRequester,
- canMove: Boolean,
- onMoveItem: (Int, UUID, MoveDirection) -> Unit,
- onFocusItem: (Int, BaseItem?) -> Unit,
- onClickItem: (Int, BaseItem?) -> Unit,
- onShowContextMenu: (Int, BaseItem?, Boolean) -> Unit,
- modifier: Modifier = Modifier,
-) {
- val focusManager = LocalFocusManager.current
- when (loadingState) {
- is LoadingState.Error -> {
- ErrorMessage(loadingState, modifier)
- }
-
- LoadingState.Pending, LoadingState.Loading -> {
- LoadingPage(modifier)
- }
-
- LoadingState.Success -> {
- if (items.isNotEmpty()) {
- LazyColumn(
- contentPadding = PaddingValues(8.dp),
- modifier =
- modifier
- .padding(bottom = 32.dp)
- .fillMaxHeight()
- .background(
- MaterialTheme.colorScheme
- .surfaceColorAtElevation(1.dp)
- .copy(alpha = .75f),
- shape = RoundedCornerShape(16.dp),
- ).focusGroup()
- .focusRestorer(),
- ) {
- itemsIndexed(items) { index, item ->
- PlaylistItem(
- item = item,
- index = index,
- onClick = {
- onClickItem.invoke(index, item)
- },
- onLongClick = {
- onShowContextMenu.invoke(index, item, true)
- },
- isPlaying =
- equalsNotNull(
- musicState.currentItemId,
- item?.id,
- ),
- isQueued = item?.id in musicState.queuedIds,
- canMove = canMove,
- moveUpAllowed = index > 0,
- moveDownAllowed = index < items.lastIndex,
- onClickMove = { direction ->
- item?.let { onMoveItem.invoke(index, item.id, direction) }
- when (direction) {
- MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up)
- MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down)
- }
- },
- onClickMore = { onShowContextMenu.invoke(index, item, false) },
- modifier =
- Modifier
- .animateItem()
- .ifElse(
- item?.type != BaseItemKind.AUDIO,
- Modifier.height(80.dp),
- ).onFocusChanged {
- if (it.hasFocus) {
- onFocusItem(index, item)
- }
- },
- )
- }
- }
- } else {
- LaunchedEffect(Unit) {
- playButtonFocusRequester.tryRequestFocus()
- }
- Box(
- contentAlignment = Alignment.Center,
- modifier = modifier.fillMaxWidth(),
- ) {
- Text(
- text = stringResource(R.string.no_results),
- style = MaterialTheme.typography.titleLarge,
- textAlign = TextAlign.Center,
- modifier = Modifier,
- )
- }
- }
- }
- }
-}
-
-@Composable
-fun PlaylistItem(
+fun CollectionFolderViewScope.PlaylistItem(
item: BaseItem?,
index: Int,
onClick: () -> Unit,
@@ -923,19 +550,18 @@ fun PlaylistItem(
isPlaying: Boolean = false,
isQueued: Boolean = false,
) {
+ val dense = viewOptions.type == ViewOptionsType.DENSE_LIST
val focused by interactionSource.collectIsFocusedAsState()
- val imageWidth = 160.dp
- val density = LocalDensity.current
- val imageWidthPx = remember(imageWidth, density) { with(density) { imageWidth.roundToPx() } }
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
modifier
- .fillMaxWidth()
- .heightIn(min = 40.dp, max = 88.dp),
+ .fillMaxWidth(),
+// .heightIn(min = 40.dp, max = 88.dp),
) {
- ListItem(
+ ListItemWrapper(
+ dense = dense,
selected = false,
onClick = onClick,
onLongClick = onLongClick,
@@ -954,19 +580,21 @@ fun PlaylistItem(
},
trailingContent = {
val duration =
- when (item?.type) {
- BaseItemKind.AUDIO -> {
- item.data.runTimeTicks
- ?.ticks
- ?.roundSeconds
- }
+ remember(item?.data?.runTimeTicks) {
+ when (item?.type) {
+ BaseItemKind.AUDIO -> {
+ item.data.runTimeTicks
+ ?.ticks
+ ?.roundSeconds
+ }
- else -> {
- item
- ?.data
- ?.runTimeTicks
- ?.ticks
- ?.roundMinutes
+ else -> {
+ item
+ ?.data
+ ?.runTimeTicks
+ ?.ticks
+ ?.roundMinutes
+ }
}
}
duration?.let { duration ->
@@ -980,15 +608,25 @@ fun PlaylistItem(
val resources = LocalResources.current
val durationText =
remember(resources, duration) { resources.formatDuration(duration) }
- Column {
- Text(
- text = durationText,
- )
- if (item?.type != BaseItemKind.AUDIO) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Column {
Text(
- text = stringResource(R.string.ends_at, endTimeStr),
- style = MaterialTheme.typography.bodySmall,
+ text = durationText,
)
+ if (item?.type != BaseItemKind.AUDIO && !dense) {
+ val style =
+ LocalTextStyle.current.let { it.copy(fontSize = it.fontSize * .75) }
+ Text(
+ text = stringResource(R.string.ends_at, endTimeStr),
+ style = style,
+ )
+ }
+ }
+ if (item?.played == true) {
+ WatchedIcon(padding = 0.dp)
}
}
}
@@ -996,41 +634,29 @@ fun PlaylistItem(
leadingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- Text(
- text = "${index + 1}.",
- style = MaterialTheme.typography.labelLarge,
- )
+ if (sortAndDirection.sort == ItemSortBy.DEFAULT) {
+// val indexText =
+// remember(index, collectionSize, sortAndDirection) {
+// when (sortAndDirection.direction) {
+// SortOrder.ASCENDING -> "${index + 1}."
+// SortOrder.DESCENDING -> "${collectionSize - index}."
+// }
+// }
+ // TODO default descending doesn't work?
+ val indexText = "${index + 1}."
+ Text(
+ text = indexText,
+ )
+ }
if (item?.type == BaseItemKind.AUDIO) {
MusicQueueMarker(
isPlaying = isPlaying,
isQueued = isQueued,
)
- } else {
- val imageType =
- remember(item) {
- if (item != null && ImageType.THUMB in item.data.imageTags.orEmpty()) {
- ImageType.THUMB
- } else {
- ImageType.PRIMARY
- }
- }
- ItemCardImage(
- item = item,
- name = item?.name,
- imageType = imageType,
- showOverlay = true,
- favorite = item?.data?.userData?.isFavorite ?: false,
- watched = item?.data?.userData?.played ?: false,
- unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
- watchedPercent = 0.0,
- numberOfVersions = item?.data?.mediaSourceCount ?: 0,
- modifier = Modifier.width(imageWidth),
- useFallbackText = false,
- fillWidth = imageWidthPx,
- )
}
+ if (item?.favorite == true) FavoriteIndicator()
}
},
modifier = Modifier.weight(1f),
@@ -1120,3 +746,10 @@ fun ConfirmMediaTypeDialog(
}
}
}
+
+private val playlistDefaultViewOptions =
+ ViewOptions(
+ spacing = 0,
+ showDetails = true,
+ type = ViewOptionsType.LIST,
+ )
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
index c5686e8f..d0017d84 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt
@@ -42,9 +42,8 @@ import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.SeerrAvailability
-import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer
-import com.github.damontecres.wholphin.data.model.hasPermission
+import com.github.damontecres.wholphin.data.model.canRequestMovies
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService
@@ -84,14 +83,12 @@ fun DiscoverMovieDetails(
}
val state by viewModel.state.collectAsState()
val userConfig by viewModel.userConfig.collectAsState(null)
+ val requestEnabled by viewModel.requestEnabled.collectAsState(false)
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
var overviewDialog by remember { mutableStateOf(null) }
var showRequestDialog by remember { mutableStateOf(false) }
- val requestStr = stringResource(R.string.request)
- val request4kStr = stringResource(R.string.request_4k)
-
when (val st = state.movie) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
@@ -153,6 +150,7 @@ fun DiscoverMovieDetails(
RequestMovieDialog(
loading = state.profileLoading,
data = state.requestData,
+ requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = {
@@ -253,7 +251,7 @@ fun DiscoverMovieDetailsContent(
}
}
},
- canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
+ canRequest = userConfig.canRequestMovies,
canCancel = canCancel,
trailers = trailers,
trailerOnClick = trailerOnClick,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
index 658e4ffe..407c6935 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt
@@ -75,6 +75,8 @@ class DiscoverMovieViewModel
val state: StateFlow = _state
val userConfig = seerrServerRepository.current.map { it?.config }
+ val requestEnabled =
+ seerrServerRepository.current.map { it?.requestMovieEnabled ?: false }
val request4kEnabled =
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
@@ -194,7 +196,7 @@ class DiscoverMovieViewModel
return@launchIO
}
try {
- seerrService.api.requestApi.requestPost(
+ val postRequest =
RequestPostRequest(
is4k = request.is4k,
mediaId = request.movieId,
@@ -208,8 +210,9 @@ class DiscoverMovieViewModel
profileId = request.profileId,
rootFolder = request.folder,
tags = emptyList(),
- ),
- )
+ )
+ Timber.v("postRequest=%s", postRequest)
+ seerrService.api.requestApi.requestPost(postRequest)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
index ef96bb7e..860af84e 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt
@@ -37,9 +37,8 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.SeerrAvailability
-import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer
-import com.github.damontecres.wholphin.data.model.hasPermission
+import com.github.damontecres.wholphin.data.model.canRequestTv
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService
@@ -88,6 +87,7 @@ fun DiscoverSeriesDetails(
val context = LocalContext.current
val resources = LocalResources.current
val state by viewModel.state.collectAsState()
+ val requestEnabled by viewModel.requestEnabled.collectAsState()
val request4kEnabled by viewModel.request4kEnabled.collectAsState()
var overviewDialog by remember { mutableStateOf(null) }
@@ -112,6 +112,7 @@ fun DiscoverSeriesDetails(
DiscoverSeriesDetailsContent(
preferences = preferences,
series = item,
+ availability = state.availability,
userConfig = userConfig,
rating = state.rating,
canCancel = state.canCancelRequest,
@@ -188,6 +189,7 @@ fun DiscoverSeriesDetails(
title = state.tvSeries.successValue?.name ?: "",
seasons = state.seasons,
seasons4k = state.seasons4k,
+ requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
onSubmit = {
showRequestSeasonDialog = false
@@ -212,6 +214,7 @@ fun DiscoverSeriesDetailsContent(
preferences: UserPreferences,
userConfig: SeerrUserConfig?,
series: TvDetails,
+ availability: SeerrAvailability,
rating: SourceRatings?,
canCancel: Boolean,
seasons: List,
@@ -275,9 +278,7 @@ fun DiscoverSeriesDetailsContent(
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
)
ExpandableDiscoverButtons(
- availability =
- SeerrAvailability.from(series.mediaInfo?.status)
- ?: SeerrAvailability.UNKNOWN,
+ availability = availability,
requestOnClick = requestOnClick,
pendingOnClick = requestOnClick,
cancelOnClick = cancelOnClick,
@@ -291,7 +292,7 @@ fun DiscoverSeriesDetailsContent(
}
}
},
- canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
+ canRequest = userConfig.canRequestTv,
canCancel = canCancel,
trailers = trailers,
trailerOnClick = trailerOnClick,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt
index b4d6f37f..c20ab9c9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt
@@ -75,6 +75,10 @@ class DiscoverSeriesViewModel
val state: StateFlow = _state
val userConfig = seerrServerRepository.current.map { it?.config }
+ val requestEnabled =
+ seerrServerRepository.current
+ .map { it?.requestTvEnabled ?: false }
+ .stateIn(viewModelScope, SharingStarted.Eagerly, false)
val request4kEnabled =
seerrServerRepository.current
.map { it?.request4kTvEnabled ?: false }
@@ -108,7 +112,7 @@ class DiscoverSeriesViewModel
backdropService.submit(discoveredItem)
updateSeasonStatus(tv)
- updateCanCancel()
+ updateAvailability()
viewModelScope.launchIO {
val rating =
@@ -190,6 +194,7 @@ class DiscoverSeriesViewModel
tv: TvDetails,
is4k: Boolean,
) {
+ Timber.v("updateSeasonStatus: id=%s, is4k=%s", tv.id, is4k)
val currentUserId = seerrServerRepository.currentUserId.first()
val seasonStatus = mutableMapOf()
val seasonAvailability = mutableMapOf()
@@ -230,7 +235,10 @@ class DiscoverSeriesViewModel
seasonStatus[season.seasonNumber] = new
}
editable[season.seasonNumber] =
- currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status
+ currentUserId == req.requestedBy?.id && (
+ req.status == RequestStatus.PENDING.status ||
+ req.status == RequestStatus.DECLINED.status
+ )
}
}
}
@@ -299,7 +307,7 @@ class DiscoverSeriesViewModel
}
}
- private suspend fun updateCanCancel() {
+ private suspend fun updateAvailability() {
val user = userConfig.firstOrNull()
val canCancel =
canUserCancelRequest(
@@ -308,7 +316,32 @@ class DiscoverSeriesViewModel
?.mediaInfo
?.requests,
)
- _state.update { it.copy(canCancelRequest = canCancel) }
+ val status =
+ state.value.tvSeries.successValue?.let { tv ->
+ val overallStatus = SeerrAvailability.from(tv.mediaInfo?.status)
+ if (overallStatus == SeerrAvailability.UNKNOWN) {
+ val userRequests =
+ tv.mediaInfo
+ ?.requests
+ ?.filter { it.requestedBy?.id == user?.id }
+ .orEmpty()
+ if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.PENDING }) {
+ SeerrAvailability.PENDING
+ } else if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.APPROVED }) {
+ SeerrAvailability.PROCESSING
+ } else {
+ SeerrAvailability.UNKNOWN
+ }
+ } else {
+ overallStatus
+ }
+ }
+ _state.update {
+ it.copy(
+ canCancelRequest = canCancel,
+ availability = status ?: SeerrAvailability.UNKNOWN,
+ )
+ }
}
fun requestOnClick() {
@@ -391,7 +424,7 @@ class DiscoverSeriesViewModel
fetchAndSetItem().await()?.let {
updateSeasonStatus(it)
- updateCanCancel()
+ updateAvailability()
}
}
}
@@ -412,7 +445,7 @@ class DiscoverSeriesViewModel
fetchAndSetItem().await()?.let {
updateSeasonStatus(it)
- updateCanCancel()
+ updateAvailability()
}
}
}
@@ -431,4 +464,5 @@ data class DiscoverSeriesState(
val canCancelRequest: Boolean = false,
val profileLoading: LoadingState = LoadingState.Pending,
val requestData: SeerrRequestData = SeerrRequestData(),
+ val availability: SeerrAvailability = SeerrAvailability.UNKNOWN,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt
index 7a009f09..ef6e54b7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt
@@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.util.LoadingState
fun RequestMovieDialog(
loading: LoadingState,
data: SeerrRequestData,
+ requestEnabled: Boolean,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
@@ -54,10 +55,11 @@ fun RequestMovieDialog(
RequestMovie(
loading = loading,
data = data,
+ requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = onSubmit,
- modifier = Modifier.padding(16.dp),
+ modifier = Modifier.padding(24.dp),
)
}
}
@@ -66,6 +68,7 @@ fun RequestMovieDialog(
fun RequestMovie(
loading: LoadingState,
data: SeerrRequestData,
+ requestEnabled: Boolean,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
@@ -90,7 +93,8 @@ fun RequestMovie(
if (data.profiles.isEmpty() && data.rootFolders.isEmpty() &&
data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty()
) {
- if (request4kEnabled) {
+ // Both enabled, show choice
+ if (requestEnabled && request4kEnabled) {
val items =
remember {
listOf(
@@ -118,12 +122,13 @@ fun RequestMovie(
)
} else {
LaunchedEffect(Unit) {
- onSubmit.invoke(MovieRequest(data, movie.id, false, null, null))
+ onSubmit.invoke(MovieRequest(data, movie.id, request4kEnabled, null, null))
}
}
} else {
RequestMovieWithOptions(
data = data,
+ requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = onSubmit,
@@ -137,12 +142,13 @@ fun RequestMovie(
@Composable
private fun RequestMovieWithOptions(
data: SeerrRequestData,
+ requestEnabled: Boolean,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
modifier: Modifier = Modifier,
) {
- var is4k by remember { mutableStateOf(request4kEnabled) }
+ var is4k by remember { mutableStateOf(!requestEnabled) }
var profile by remember(is4k) {
mutableStateOf(
if (is4k) {
@@ -185,7 +191,7 @@ private fun RequestMovieWithOptions(
.fillMaxWidth()
.padding(bottom = 8.dp),
) {
- if (request4kEnabled) {
+ if (requestEnabled && request4kEnabled) {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
@@ -377,7 +383,7 @@ fun MovieRequestPreview() {
),
profiles4k =
listOf(
- SeerrProfile(1, "HD", true),
+ SeerrProfile(1, "4K", true),
SeerrProfile(2, "Ultra HD", false),
),
rootFolders4k =
@@ -385,7 +391,8 @@ fun MovieRequestPreview() {
SeerrRootFolder(1, "/movies", "400GB", true),
),
),
- request4kEnabled = false,
+ requestEnabled = true,
+ request4kEnabled = true,
movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"),
onSubmit = {},
modifier =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt
index bc11825f..91a10279 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt
@@ -65,11 +65,12 @@ fun RequestSeasons(
seasons: List,
seasons4k: List,
data: SeerrRequestData,
+ requestEnabled: Boolean,
request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit,
modifier: Modifier = Modifier,
) {
- var is4k by remember { mutableStateOf(request4kEnabled) }
+ var is4k by remember { mutableStateOf(!requestEnabled) }
val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons }
val allSeasonNumbers =
@@ -161,7 +162,7 @@ fun RequestSeasons(
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
- if (request4kEnabled) {
+ if (requestEnabled && request4kEnabled) {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
@@ -391,6 +392,7 @@ fun RequestSeasonsDialog(
data: SeerrRequestData,
seasons: List,
seasons4k: List,
+ requestEnabled: Boolean,
request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit,
onDismissRequest: () -> Unit,
@@ -418,6 +420,7 @@ fun RequestSeasonsDialog(
data = data,
seasons = seasons,
seasons4k = seasons4k,
+ requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
onSubmit = onSubmit,
modifier =
@@ -475,6 +478,7 @@ fun RequestSeasonsPreview() {
SeerrRootFolder(1, "/tv", "400GB", true),
),
),
+ requestEnabled = true,
request4kEnabled = true,
onSubmit = { },
modifier = Modifier.width(400.dp),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
index a45fbda0..97aaaf86 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt
@@ -145,11 +145,10 @@ class EpisodeViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
- val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
}
_state.update { it.copy(chosenStreams = chosen) }
}
@@ -163,7 +162,6 @@ class EpisodeViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
- val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@@ -171,10 +169,7 @@ class EpisodeViewModel
trackIndex = trackIndex,
type = type,
)
- val chosen =
- result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
- }
+ val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
_state.update { it.copy(chosenStreams = chosen) }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
index 470f33c2..bcba145c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt
@@ -149,7 +149,11 @@ class LiveTvViewModel
number = it.channelNumber,
name = it.channelName ?: it.name,
imageUrl =
- imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY),
+ imageUrlService.getItemImageUrl(
+ itemId = it.id,
+ imageType = ImageType.PRIMARY,
+ tag = it.imageTags?.get(ImageType.PRIMARY),
+ ),
favorite = it.userData?.isFavorite == true,
)
}
@@ -299,6 +303,7 @@ class LiveTvViewModel
imageUrlService.getItemImageUrl(
dto.id,
ImageType.PRIMARY,
+ tag = dto.imageTags?.get(ImageType.PRIMARY),
),
)
if (index == 0) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
index db6a6f6a..9a8dd033 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt
@@ -223,11 +223,10 @@ class MovieViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
- val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
}
_state.update { it.copy(chosenStreams = chosen) }
}
@@ -241,7 +240,6 @@ class MovieViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
- val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@@ -249,10 +247,7 @@ class MovieViewModel
trackIndex = trackIndex,
type = type,
)
- val chosen =
- result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
- }
+ val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
_state.update { it.copy(chosenStreams = chosen) }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
index 32f1d484..3bbc3a6b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt
@@ -561,11 +561,10 @@ class SeriesViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
- val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
+ itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
}
_state.update { it.copy(chosenStreams = chosen) }
}
@@ -579,7 +578,6 @@ class SeriesViewModel
) {
viewModelScope.launchIO {
val prefs = userPreferencesService.getCurrent()
- val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
@@ -587,10 +585,7 @@ class SeriesViewModel
trackIndex = trackIndex,
type = type,
)
- val chosen =
- result?.let {
- itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
- }
+ val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs)
_state.update { it.copy(chosenStreams = chosen) }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
index 847e2e69..d02ba907 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt
@@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
+import com.github.damontecres.wholphin.services.HomeDataService
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.LatestNextUpService
@@ -62,6 +63,7 @@ class HomeViewModel
val serverReportService: ServerReportService,
private val navDrawerService: NavDrawerService,
private val homeSettingsService: HomeSettingsService,
+ private val homeDataService: HomeDataService,
private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService,
private val backdropService: BackdropService,
@@ -186,7 +188,7 @@ class HomeViewModel
semaphore.withPermit {
Timber.v("Fetching row: %s", row)
try {
- homeSettingsService.fetchDataForRow(
+ homeDataService.fetchDataForRow(
row = row.config,
scope = viewModelScope,
prefs = prefs,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt
index ad433c23..ae958a93 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt
@@ -33,6 +33,9 @@ sealed interface HomeSettingsDestination : NavKey {
@Serializable
data object GlobalSettings : HomeSettingsDestination
+ @Serializable
+ data object SourceSettings : HomeSettingsDestination
+
@Serializable
data object Presets : HomeSettingsDestination
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
index 894980c4..74c546b5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt
@@ -31,10 +31,6 @@ fun HomeSettingsGlobal(
preferences: AppPreferences,
onPreferenceChange: (AppPreferences) -> Unit,
onClickResize: (Int) -> Unit,
- onClickSave: () -> Unit,
- onClickLoad: () -> Unit,
- onClickLoadWeb: () -> Unit,
- onClickReset: () -> Unit,
onClickViewNextUp: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -146,62 +142,6 @@ fun HomeSettingsGlobal(
modifier = Modifier,
)
}
- item {
- HomeSettingsListItem(
- selected = false,
- headlineText = stringResource(R.string.save_to_server),
- leadingContent = {
- Text(
- text = stringResource(R.string.fa_cloud_arrow_up),
- fontFamily = FontAwesome,
- )
- },
- onClick = onClickSave,
- modifier = Modifier,
- )
- }
- item {
- HomeSettingsListItem(
- selected = false,
- headlineText = stringResource(R.string.load_from_server),
- leadingContent = {
- Text(
- text = stringResource(R.string.fa_cloud_arrow_down),
- fontFamily = FontAwesome,
- )
- },
- onClick = onClickLoad,
- modifier = Modifier,
- )
- }
- item {
- HomeSettingsListItem(
- selected = false,
- headlineText = stringResource(R.string.load_from_web_client),
- leadingContent = {
- Text(
- text = stringResource(R.string.fa_download),
- fontFamily = FontAwesome,
- )
- },
- onClick = onClickLoadWeb,
- modifier = Modifier,
- )
- }
- item {
- HomeSettingsListItem(
- selected = false,
- headlineText = stringResource(R.string.reset),
- leadingContent = {
- Text(
- text = stringResource(R.string.fa_arrows_rotate),
- fontFamily = FontAwesome,
- )
- },
- onClick = onClickReset,
- modifier = Modifier,
- )
- }
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
index 5a2d6c78..0bd6b7db 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt
@@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.main.settings
+import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -64,11 +65,14 @@ fun HomeSettingsPage(
var showConfirmDialog by remember { mutableStateOf(null) }
var searchForDialog by remember { mutableStateOf(null) }
var showRemovedNextUpDialog by remember { mutableStateOf(false) }
+ var showSaveDialog by remember { mutableStateOf(false) }
val state by viewModel.state.collectAsState()
+ val serverPluginActive by viewModel.serverPluginActive.collectAsState()
var position by rememberPosition(0, 0)
// TODO discover rows
val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
+ val settingsHaveChanged by viewModel.settingsChanged.collectAsState()
// Adds a row, waits until its done loading, then scrolls to the new row
fun addRow(
@@ -86,6 +90,10 @@ fun HomeSettingsPage(
}
}
+ BackHandler(settingsHaveChanged) {
+ showSaveDialog = true
+ }
+
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
@@ -137,6 +145,7 @@ fun HomeSettingsPage(
position = RowColumn(index, 0)
}
},
+ onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) },
modifier = destModifier,
)
}
@@ -282,6 +291,16 @@ fun HomeSettingsPage(
}
},
onClickResize = { viewModel.resizeCards(it) },
+ onClickViewNextUp = {
+ showRemovedNextUpDialog = true
+ },
+ modifier = destModifier,
+ )
+ }
+
+ HomeSettingsDestination.SourceSettings -> {
+ HomeSettingsSourcePage(
+ source = state.source,
onClickSave = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_server_settings) {
@@ -294,6 +313,13 @@ fun HomeSettingsPage(
viewModel.loadFromRemote()
}
},
+ serverPluginActive = serverPluginActive,
+ onClickLoadPlugin = {
+ showConfirmDialog =
+ ShowConfirm(R.string.overwrite_local_settings) {
+ viewModel.loadFromRemotePlugin()
+ }
+ },
onClickLoadWeb = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_local_settings) {
@@ -306,9 +332,6 @@ fun HomeSettingsPage(
addRow(false) { viewModel.resetToDefault() }
}
},
- onClickViewNextUp = {
- showRemovedNextUpDialog = true
- },
modifier = destModifier,
)
}
@@ -375,6 +398,29 @@ fun HomeSettingsPage(
)
}
}
+ if (showSaveDialog) {
+ SaveHomeSettingsDialog(
+ onDismissRequest = { showSaveDialog = false },
+ onSaveLocal = {
+ scope.launch {
+ viewModel.saveToLocal().join()
+ viewModel.navigationManager.goBack()
+ }
+ },
+ onSaveServer = {
+ scope.launch {
+ viewModel.saveToRemote().join()
+ viewModel.navigationManager.goBack()
+ }
+ },
+ onDiscard = {
+ scope.launch {
+ viewModel.discardChanges().join()
+ viewModel.navigationManager.goBack()
+ }
+ },
+ )
+ }
}
data class ShowConfirm(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt
index dc527ed4..72289463 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt
@@ -49,6 +49,11 @@ enum class MoveDirection {
DOWN,
}
+private const val POS_ADD_ROW = 0
+private const val POS_SOURCE = POS_ADD_ROW + 1
+private const val POS_SETTINGS = POS_SOURCE + 1
+private const val POS_PRESETS = POS_SETTINGS + 1
+
@Composable
fun HomeSettingsRowList(
state: HomePageSettingsState,
@@ -58,13 +63,14 @@ fun HomeSettingsRowList(
onClickPresets: () -> Unit,
onClickMove: (MoveDirection, Int) -> Unit,
onClickDelete: (Int) -> Unit,
+ onClickSourceSettings: () -> Unit,
modifier: Modifier,
) {
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
- val itemsBeforeRows = 4
+ val itemsBeforeRows = POS_PRESETS + 1 // +1 for divider
val focusRequesters =
remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
@@ -80,7 +86,7 @@ fun HomeSettingsRowList(
modifier =
modifier
.fillMaxHeight()
- .focusRestorer(focusRequesters[0]),
+ .focusRestorer(focusRequesters[POS_ADD_ROW]),
) {
item {
HomeSettingsListItem(
@@ -93,10 +99,30 @@ fun HomeSettingsRowList(
)
},
onClick = {
- position = 0
+ position = POS_ADD_ROW
onClickAdd.invoke()
},
- modifier = Modifier.focusRequester(focusRequesters[0]),
+ modifier = Modifier.focusRequester(focusRequesters[POS_ADD_ROW]),
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = "Source",
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_download),
+ fontFamily = FontAwesome,
+ )
+ },
+ supportingContent = {
+ Text(text = stringResource(state.source.stringResId))
+ },
+ onClick = {
+ position = POS_SOURCE
+ onClickSourceSettings.invoke()
+ },
+ modifier = Modifier.focusRequester(focusRequesters[POS_SOURCE]),
)
}
item {
@@ -110,10 +136,10 @@ fun HomeSettingsRowList(
)
},
onClick = {
- position = 1
+ position = POS_SETTINGS
onClickSettings.invoke()
},
- modifier = Modifier.focusRequester(focusRequesters[1]),
+ modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]),
)
}
item {
@@ -132,10 +158,10 @@ fun HomeSettingsRowList(
)
},
onClick = {
- position = 2
+ position = POS_PRESETS
onClickPresets.invoke()
},
- modifier = Modifier.focusRequester(focusRequesters[2]),
+ modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]),
)
}
item {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt
new file mode 100644
index 00000000..8139b651
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt
@@ -0,0 +1,111 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.services.HomePageSettingsSource
+import com.github.damontecres.wholphin.ui.FontAwesome
+import com.github.damontecres.wholphin.ui.tryRequestFocus
+
+@Composable
+fun HomeSettingsSourcePage(
+ source: HomePageSettingsSource,
+ onClickSave: () -> Unit,
+ onClickLoad: () -> Unit,
+ onClickLoadWeb: () -> Unit,
+ onClickReset: () -> Unit,
+ serverPluginActive: Boolean,
+ onClickLoadPlugin: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val firstFocus: FocusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
+ Column(modifier = modifier) {
+ Text(
+ text = stringResource(R.string.settings),
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .focusRestorer(firstFocus),
+ ) {
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.load_from_server),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_cloud_arrow_down),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickLoad,
+ modifier = Modifier,
+ )
+ }
+ if (serverPluginActive) {
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.load_from_server_plugin),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_download),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickLoadPlugin,
+ modifier = Modifier,
+ )
+ }
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.load_from_web_client),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_download),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickLoadWeb,
+ modifier = Modifier,
+ )
+ }
+ item {
+ HomeSettingsListItem(
+ selected = false,
+ headlineText = stringResource(R.string.reset),
+ leadingContent = {
+ Text(
+ text = stringResource(R.string.fa_arrows_rotate),
+ fontFamily = FontAwesome,
+ )
+ },
+ onClick = onClickReset,
+ modifier = Modifier,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
index e3eafcbb..1082a23f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt
@@ -24,16 +24,18 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
+import com.github.damontecres.wholphin.services.HomeDataService
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
+import com.github.damontecres.wholphin.services.HomePageSettingsSource
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.NavDrawerService
+import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository
+import com.github.damontecres.wholphin.services.ServerPluginApi
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.getRecentlyAddedTitle
-import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
-import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.services.viewOptionsForCollectionType
import com.github.damontecres.wholphin.ui.formatTypeName
import com.github.damontecres.wholphin.ui.launchDefault
@@ -48,13 +50,16 @@ import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@@ -76,37 +81,55 @@ class HomeSettingsViewModel
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val homeSettingsService: HomeSettingsService,
+ private val homeDataService: HomeDataService,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService,
private val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository,
+ private val serverPluginApi: ServerPluginApi,
val preferencesDataStore: DataStore,
- @param:IoCoroutineScope private val ioScope: CoroutineScope,
+ val navigationManager: NavigationManager,
) : ViewModel() {
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
val state: StateFlow = _state
+ val serverPluginActive get() = serverRepository.serverPluginInstalled
+ val currentUser
+ get() =
+ serverRepository.currentUserFlow.filterNotNull().stateIn(
+ viewModelScope,
+ SharingStarted.Eagerly,
+ serverRepository.currentUser!!,
+ )
+ val settingsChanged =
+ state
+ .map {
+ HomePageSettings(
+ rows = it.rows.map { it.config },
+ version = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
+ ) != originalSettings
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, false)
+
private var idCounter by Delegates.notNull()
val discoverEnabled = seerrServerRepository.active
- private var originalLocalSettings: HomePageSettings? = null
- private var originalRemoteSettings: HomePageSettings? = null
+ private var originalSettings: HomePageSettings? = null
init {
- addCloseable { saveToLocal() }
viewModelScope.launchIO {
+ val user = serverRepository.currentUser ?: return@launchIO
val userDto = serverRepository.currentUserDto ?: return@launchIO
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val currentSettings =
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
- originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id)
- originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id)
+ originalSettings = currentSettings.asHomePageSettings()
Timber.v("currentSettings=%s", currentSettings)
idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(
+ source = currentSettings.source,
libraries = libraries,
rows = currentSettings.rows,
)
@@ -134,7 +157,7 @@ class HomeSettingsViewModel
viewModelScope.async(WholphinDispatchers.IO) {
semaphore.withPermit {
try {
- homeSettingsService.fetchDataForRow(
+ homeDataService.fetchDataForRow(
row = row.config,
scope = viewModelScope,
prefs = prefs,
@@ -496,7 +519,7 @@ class HomeSettingsViewModel
}
}
- fun saveToRemote() {
+ fun saveToRemote(): Job =
viewModelScope.launchIO {
serverRepository.currentUser?.let { user ->
Timber.d("Saving home settings to remote")
@@ -506,6 +529,10 @@ class HomeSettingsViewModel
try {
Timber.d("saveToRemote")
homeSettingsService.saveToServer(user.id, settings)
+ homeSettingsService.updateCurrent(
+ HomePageSettingsSource.SERVER_PROFILE,
+ settings,
+ )
showSaveToast()
} catch (ex: Exception) {
Timber.e(ex)
@@ -513,7 +540,6 @@ class HomeSettingsViewModel
}
}
}
- }
fun loadFromRemote() {
viewModelScope.launchIO {
@@ -530,7 +556,10 @@ class HomeSettingsViewModel
}
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
- it.copy(rows = newRows)
+ it.copy(
+ source = HomePageSettingsSource.SERVER_PROFILE,
+ rows = newRows,
+ )
}
} else {
Timber.v("No remote settings")
@@ -560,7 +589,10 @@ class HomeSettingsViewModel
Timber.v("Got web settings")
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
- it.copy(rows = result.rows)
+ it.copy(
+ source = HomePageSettingsSource.LOCAL,
+ rows = result.rows,
+ )
}
} else {
Timber.v("No web settings")
@@ -575,34 +607,19 @@ class HomeSettingsViewModel
}
}
- fun saveToLocal() {
- // This uses injected ioScope so that it will still run when the page is closing
- ioScope.launchIO {
+ fun saveToLocal(): Job =
+ viewModelScope.launchIO {
serverRepository.currentUser?.let { user ->
val rows = state.value.rows.map { it.config }
val settings =
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
try {
Timber.d("saveToLocal")
- // Only save if there are changes based on original source
- val shouldSave =
- if (originalLocalSettings != null) {
- originalLocalSettings != settings
- } else if (originalRemoteSettings != null) {
- originalRemoteSettings != settings
- } else {
- true
- }
- if (shouldSave) {
- homeSettingsService.saveToLocal(user.id, settings)
- homeSettingsService.updateCurrent(user.id, settings)
- showSaveToast()
- } else {
- Timber.d("No changes")
- }
- } catch (ex: UnsupportedHomeSettingsVersionException) {
- Timber.w(ex, "Overwriting local settings")
homeSettingsService.saveToLocal(user.id, settings)
+ homeSettingsService.updateCurrent(
+ HomePageSettingsSource.LOCAL,
+ settings,
+ )
showSaveToast()
} catch (ex: Exception) {
Timber.e(ex)
@@ -610,17 +627,11 @@ class HomeSettingsViewModel
}
}
}
- }
private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
_state.update {
update.invoke(it)
}
- serverRepository.currentUser?.id?.let { userId ->
- homeSettingsService.currentSettings.update {
- HomePageResolvedSettings(userId, state.value.rows)
- }
- }
}
fun resizeCards(relative: Int) {
@@ -649,12 +660,32 @@ class HomeSettingsViewModel
fun resetToDefault() =
viewModelScope.launchIO {
- val userId = serverRepository.currentUser?.id ?: return@launchIO
+ val user = serverRepository.currentUser ?: return@launchIO
_state.update { it.copy(loading = LoadingState.Loading) }
- val result = homeSettingsService.createDefault(userId)
+ val result =
+ if (serverPluginActive.value) {
+ try {
+ val settings =
+ homeSettingsService.fetchSettingsFor(
+ user.id,
+ HomePageSettingsSource.PLUGIN,
+ )
+ settings
+ } catch (ex: Exception) {
+ Timber.e(ex, "Plugin is active, but error fetching home settings")
+ null
+ }
+ } else {
+ null
+ } ?: homeSettingsService.createDefault(user.id)
+
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
- it.copy(rows = result.rows, rowData = emptyList())
+ it.copy(
+ source = result.source,
+ rows = result.rows,
+ rowData = emptyList(),
+ )
}
fetchRowData()
}
@@ -746,6 +777,10 @@ class HomeSettingsViewModel
is HomeRowConfig.TvChannels -> {
it.config.updateViewOptions(preset.liveTv)
}
+
+ is HomeRowConfig.CustomEndpoint -> {
+ it.config
+ }
}
it.copy(config = newConfig)
}
@@ -767,6 +802,43 @@ class HomeSettingsViewModel
}
}
+ fun loadFromRemotePlugin() {
+ viewModelScope.launchIO {
+ Timber.d("Loading home settings from server plugin")
+ try {
+ // TODO should this remove local settings?
+ // TODO how to set up priorities
+ _state.update { it.copy(loading = LoadingState.Loading) }
+ val result = serverPluginApi.fetchHomePageSettings()
+ if (result != null) {
+ Timber.v("Got remote settings")
+ val newRows =
+ result.rows.mapIndexed { index, config ->
+ homeSettingsService.resolve(index, config)
+ }
+ idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
+ _state.update {
+ it.copy(
+ source = HomePageSettingsSource.PLUGIN,
+ rows = newRows,
+ )
+ }
+ } else {
+ Timber.v("No server plugin settings")
+ showToast(context, "No server plugin settings found")
+ }
+ fetchRowData()
+ } catch (ex: UnsupportedHomeSettingsVersionException) {
+ // TODO
+ Timber.w(ex)
+ showToast(context, "Error: ${ex.localizedMessage}")
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ showToast(context, "Error: ${ex.localizedMessage}")
+ }
+ }
+ }
+
fun onConfigAction(
row: HomeRowConfigDisplay,
action: HomeRowConfigAction,
@@ -861,10 +933,13 @@ class HomeSettingsViewModel
fetchRowData()
}
}
+
+ fun discardChanges(): Job = Job().apply { complete() }
}
data class HomePageSettingsState(
val loading: LoadingState,
+ val source: HomePageSettingsSource,
val rows: List,
val rowData: List,
val libraries: List,
@@ -873,6 +948,7 @@ data class HomePageSettingsState(
val EMPTY =
HomePageSettingsState(
LoadingState.Pending,
+ HomePageSettingsSource.UNSET,
emptyList(),
emptyList(),
emptyList(),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt
new file mode 100644
index 00000000..c7531b6d
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt
@@ -0,0 +1,54 @@
+package com.github.damontecres.wholphin.ui.main.settings
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.unit.dp
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.ui.components.DialogItem
+import com.github.damontecres.wholphin.ui.components.DialogParams
+import com.github.damontecres.wholphin.ui.components.DialogPopup
+
+@Composable
+fun SaveHomeSettingsDialog(
+ onDismissRequest: () -> Unit,
+ onSaveLocal: () -> Unit,
+ onSaveServer: () -> Unit,
+ onDiscard: () -> Unit,
+) {
+ val resources = LocalResources.current
+ val params =
+ remember(resources) {
+ DialogParams(
+ fromLongClick = false,
+ title = resources.getString(R.string.save),
+ items =
+ listOf(
+ DialogItem(
+ text = R.string.save_to_local,
+ iconStringRes = R.string.fa_cloud_arrow_down,
+ onClick = onSaveLocal,
+ ),
+ DialogItem(
+ text = R.string.save_to_server,
+ iconStringRes = R.string.fa_cloud_arrow_up,
+ onClick = onSaveServer,
+ ),
+ DialogItem(
+ text = "Discard",
+ icon = Icons.Default.Delete,
+ iconColor = Color.Red,
+ onClick = onDiscard,
+ ),
+ ),
+ )
+ }
+ DialogPopup(
+ params = params,
+ onDismissRequest = onDismissRequest,
+ elevation = 3.dp,
+ )
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
index a65c97e8..110cb637 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt
@@ -134,7 +134,7 @@ sealed class Destination(
@Serializable
data class Slideshow(
- val parentId: UUID,
+ val parentId: UUID?,
val index: Int,
val filter: CollectionFolderFilter,
val sortAndDirection: SortAndDirection,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
index 17906fe2..c26cb891 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt
@@ -80,6 +80,7 @@ import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay
+import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@@ -573,6 +574,7 @@ fun NavigationDrawerScope.ProfileIcon(
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
+ val focused by interactionSource.collectIsFocusedAsState()
NavigationDrawerItem(
modifier = modifier,
selected = false,
@@ -595,7 +597,7 @@ fun NavigationDrawerScope.ProfileIcon(
interactionSource = interactionSource,
) {
Text(
- modifier = Modifier,
+ modifier = Modifier.enableMarquee(focused),
text = user.name ?: user.id.toString(),
maxLines = 1,
)
@@ -639,7 +641,7 @@ fun NavigationDrawerScope.IconNavItem(
interactionSource = interactionSource,
) {
Text(
- modifier = Modifier,
+ modifier = Modifier.enableMarquee(focused),
text = text,
maxLines = 1,
)
@@ -733,7 +735,7 @@ fun NavigationDrawerScope.NavItem(
interactionSource = interactionSource,
) {
Text(
- modifier = Modifier,
+ modifier = Modifier.enableMarquee(focused),
text = library.name(context),
maxLines = 1,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
index 871264c2..b17036b8 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
@@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.PlaylistItem
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaylistCreationResult
@@ -158,7 +159,11 @@ class PlayExternalViewModel
is PlaylistItem.Media -> playlistItem.item
}
val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig)
- val plc = streamChoiceService.getPlaybackLanguageChoice(item.data)
+ val subtitleStc =
+ streamChoiceService.getSeriesTrackChoices(
+ item.data,
+ SeriesTrackChoiceType.SUBTITLE,
+ )
if (mediaSource == null) {
Timber.w("Media source is null")
return@launchDefault
@@ -168,13 +173,14 @@ class PlayExternalViewModel
val subtitleIndex =
streamChoiceService
.chooseSubtitleStream(
+ item = item.data,
source = mediaSource,
audioStream = null,
- seriesId = item.data.seriesId,
itemPlayback = playbackConfig,
- plc = plc,
+ stc = subtitleStc,
prefs = prefs,
- )?.index
+ )?.stream
+ ?.index
val externalSubtitles =
mediaSource.mediaStreams
?.filter { it.isExternal }
@@ -237,6 +243,8 @@ class PlayExternalViewModel
}
// VLC
+ // A zero position alone does not override VLC's saved resume position.
+ putExtra("from_start", positionMs == 0L)
if (subtitleUrls.isNotEmpty()) {
putExtra("subtitles_location", subtitleUrls.first().toString())
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
index 491498cb..3165246f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt
@@ -42,8 +42,10 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
+import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
+import com.github.damontecres.wholphin.ui.formatMaxBitrate
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
@@ -60,6 +62,7 @@ enum class PlaybackDialogType {
PLAYBACK_SPEED,
VIDEO_SCALE,
SUBTITLE_DELAY,
+ BITRATE,
}
data class PlaybackSettings(
@@ -73,6 +76,7 @@ data class PlaybackSettings(
val subtitleDelay: Duration,
val hasSubtitleDownloadPermission: Boolean,
val playbackSpeedEnabled: Boolean,
+ val maxBitrate: Long,
)
/**
@@ -162,6 +166,13 @@ fun PlaybackDialog(
),
)
}
+ add(
+ BottomDialogItem(
+ data = PlaybackDialogType.BITRATE,
+ headline = stringResource(R.string.max_bitrate),
+ supporting = remember(settings.maxBitrate) { formatMaxBitrate(settings.maxBitrate) },
+ ),
+ )
add(
BottomDialogItem(
data = PlaybackDialogType.DEBUG,
@@ -271,6 +282,29 @@ fun PlaybackDialog(
}
}
}
+
+ PlaybackDialogType.BITRATE -> {
+ val choices =
+ AppPreference.bitrateValues.map { bitrate ->
+ BottomDialogItem(
+ data = bitrate,
+ headline = formatMaxBitrate(bitrate),
+ supporting = null,
+ )
+ }
+ BottomDialog(
+ choices = choices,
+ currentChoice = choices.firstOrNull { it.data == settings.maxBitrate },
+ onDismissRequest = {
+ onControllerInteraction.invoke()
+ onDismissRequest.invoke()
+ },
+ onSelectChoice = { _, choice ->
+ onPlaybackActionClick.invoke(PlaybackAction.ChangeBitrate(choice.data))
+ },
+ gravity = leftGravity,
+ )
+ }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
index 483083f2..86d64651 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
@@ -322,6 +322,10 @@ fun PlaybackPageContent(
player.seekToPrevious()
}
}
+
+ is PlaybackAction.ChangeBitrate -> {
+ viewModel.changeMaxBitrate(it.maxBitrate)
+ }
}
}
@@ -752,6 +756,7 @@ fun PlaybackPageContent(
// TODO Passing through audio prevents changing playback speed
// See https://github.com/damontecres/Wholphin/issues/164
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null,
+ maxBitrate = state.maxBitrate,
),
onDismissRequest = {
playbackDialog =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt
index b42b7151..cca3a0d0 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt
@@ -24,6 +24,7 @@ data class PlaybackState(
val nextUp: BaseItem? = null,
val playlistIndex: Int = 0,
val playlist: Playlist = Playlist(emptyList()),
+ val maxBitrate: Long = -1L,
) {
val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size
val hasPrevious: Boolean get() = playlistIndex > 0
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
index 012038a8..24bd8ee9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
@@ -5,6 +5,7 @@ import android.media.MediaCodecList
import android.os.Build
import android.widget.Toast
import androidx.annotation.OptIn
+import androidx.annotation.VisibleForTesting
import androidx.compose.ui.unit.Density
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
@@ -34,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreference
@@ -198,7 +200,7 @@ class PlaybackViewModel
val currentUserDto = serverRepository.currentUserDtoFlow
- // Exposed for testing
+ @VisibleForTesting
val initJob: Job
init {
@@ -290,8 +292,16 @@ class PlaybackViewModel
*/
private suspend fun init() {
musicService.stop()
- _state.update { it.copy(nextUp = null) }
this.preferences = userPreferencesService.getCurrent()
+ val maxBitrate =
+ preferences.appPreferences.playbackPreferences.maxBitrate
+ .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
+ _state.update {
+ it.copy(
+ nextUp = null,
+ maxBitrate = maxBitrate,
+ )
+ }
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
addCloseable { refreshRateService.resetRefreshRate() }
}
@@ -489,7 +499,16 @@ class PlaybackViewModel
null
}
- val plc = streamChoiceService.getPlaybackLanguageChoice(base)
+ val audioStc =
+ streamChoiceService.getSeriesTrackChoices(
+ base,
+ SeriesTrackChoiceType.AUDIO,
+ )
+ val subtitleStc =
+ streamChoiceService.getSeriesTrackChoices(
+ base,
+ SeriesTrackChoiceType.AUDIO,
+ )
if (mediaSource == null && !isLiveTv) {
showToast(
@@ -518,30 +537,31 @@ class PlaybackViewModel
val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty()
val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty()
- val audioStream =
+ val audioResult =
mediaSource?.let {
streamChoiceService
.chooseAudioStream(
source = mediaSource,
- seriesId = base.seriesId,
+ item = base,
itemPlayback = itemPlayback,
- plc = plc,
+ stc = audioStc,
prefs = preferences,
)
}
- val audioIndex = audioStream?.index
+ val audioIndex = audioResult?.stream?.index
val subtitleIndex =
mediaSource?.let {
streamChoiceService
.chooseSubtitleStream(
+ item = base,
source = mediaSource,
- audioStream = audioStream,
- seriesId = base.seriesId,
+ audioStream = audioResult?.stream,
itemPlayback = itemPlayback,
- plc = plc,
+ stc = subtitleStc,
prefs = preferences,
- )?.index
+ )?.stream
+ ?.index
}
Timber.d(
"Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s",
@@ -616,6 +636,7 @@ class PlaybackViewModel
positionMs: Long = 0,
enableDirectPlay: Boolean = !this.forceTranscoding,
enableDirectStream: Boolean = !this.forceTranscoding,
+ maxBitrate: Long = this.state.value.maxBitrate,
): Unit =
withContext(WholphinDispatchers.IO) {
val itemId = item.id
@@ -626,7 +647,8 @@ class PlaybackViewModel
state.value.currentPlayback?.let { currentPlayback ->
if (currentPlayback.item.id == item.id &&
currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
- enableDirectPlay
+ enableDirectPlay &&
+ state.value.maxBitrate == maxBitrate
) {
val wasSuccessful =
changeStreamsDirectPlay(
@@ -639,18 +661,17 @@ class PlaybackViewModel
}
Timber.i(
- "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s",
+ "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s",
itemId,
audioIndex,
subtitleIndex,
enableDirectPlay,
enableDirectStream,
positionMs,
+ maxBitrate,
)
+ _state.update { it.copy(maxBitrate = maxBitrate) }
- val maxBitrate =
- preferences.appPreferences.playbackPreferences.maxBitrate
- .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
val response by
api.mediaInfoApi
.getPostedPlaybackInfo(
@@ -696,6 +717,7 @@ class PlaybackViewModel
api.videosApi.getVideoStreamUrl(
itemId = itemId,
mediaSourceId = source.id,
+ liveStreamId = source.liveStreamId,
static = true,
tag = source.eTag,
playSessionId = response.playSessionId,
@@ -955,7 +977,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex(
source = currentPlayback.mediaSourceInfo,
audioStreamIndex = index,
- seriesId = currentItem.item.data.seriesId,
+ item = currentItem.item.data,
subtitleIndex = currentPlayback.subtitleIndex,
prefs = preferences,
)
@@ -991,7 +1013,7 @@ class PlaybackViewModel
streamChoiceService.resolveSubtitleIndex(
source = currentPlayback.mediaSourceInfo,
audioStreamIndex = currentPlayback.audioIndex,
- seriesId = currentItem.item.data.seriesId,
+ item = currentItem.item.data,
subtitleIndex = index,
prefs = preferences,
)
@@ -1009,6 +1031,26 @@ class PlaybackViewModel
}
}
+ fun changeMaxBitrate(newMaxBitrate: Long) {
+ viewModelScope.launchIO {
+ val currentPlayback = state.value.currentPlayback
+ if (currentPlayback != null) {
+ Timber.d("Changing bitrate to %s", newMaxBitrate)
+ changeStreams(
+ item = currentItem.item,
+ sourceId = currentPlayback.mediaSourceInfo.id,
+ audioIndex = currentPlayback.audioIndex,
+ subtitleIndex = currentPlayback.subtitleIndex,
+ positionMs = onMain { player.currentPosition },
+ enableDirectPlay = true,
+ maxBitrate = newMaxBitrate,
+ )
+ } else {
+ Timber.w("Trying to change bitrate, but currentPlayback is null")
+ }
+ }
+ }
+
internal suspend fun saveTrackSelection(
trackIndex: Int,
type: MediaStreamType,
@@ -1110,7 +1152,7 @@ class PlaybackViewModel
}
// Variables for tracking segment state
- // Exposed for testing
+ @VisibleForTesting
internal var segmentJob: Job? = null
private val autoSkippedSegments = mutableSetOf()
private val outroShownSegments = mutableSetOf()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt
index ba77d8b7..45fcdd4c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -122,6 +123,10 @@ sealed interface PlaybackAction {
data object Previous : PlaybackAction
data object Next : PlaybackAction
+
+ data class ChangeBitrate(
+ val maxBitrate: Long,
+ ) : PlaybackAction
}
/**
@@ -561,12 +566,19 @@ fun BottomDialog(
gravity: Int,
currentChoice: BottomDialogItem? = null,
) {
+ val initialIndex =
+ remember(choices, currentChoice) { choices.indexOfFirstOrNull { it == currentChoice } } ?: 0
+ val listState =
+ rememberLazyListState(
+ initialFirstVisibleItemIndex = initialIndex,
+ )
val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
+ val bringIntoViewRequesters =
+ remember(choices.size) { List(choices.size) { BringIntoViewRequester() } }
if (currentChoice != null) {
LaunchedEffect(Unit) {
- choices.indexOfFirstOrNull { it == currentChoice }?.let {
- focusRequesters.getOrNull(it)?.tryRequestFocus()
- }
+ bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView()
+ focusRequesters.getOrNull(initialIndex)?.tryRequestFocus()
}
}
// TODO enforcing a width ends up ignore the gravity
@@ -591,6 +603,7 @@ fun BottomDialog(
),
) {
LazyColumn(
+ state = listState,
modifier =
Modifier
.fillMaxWidth()
@@ -624,7 +637,10 @@ fun BottomDialog(
}
},
interactionSource = interactionSource,
- modifier = Modifier.focusRequester(focusRequesters[index]),
+ modifier =
+ Modifier
+ .bringIntoViewRequester(bringIntoViewRequesters[index])
+ .focusRequester(focusRequesters[index]),
)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
index 0a843dcb..81f4833b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
@@ -1,6 +1,13 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.res.stringResource
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
+import com.github.damontecres.wholphin.data.model.SeerrServer
+import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.preferences.AppPreference
import kotlinx.serialization.Serializable
@@ -38,9 +45,53 @@ enum class PreferenceScreenOption {
SCREENSAVER,
SKIP_SEGMENTS,
EXPERIMENTAL,
+ HDR_FORMATS,
;
companion object {
fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC
}
}
+
+@Composable
+fun rememberSeerrDialogBody(
+ user: SeerrUser,
+ server: SeerrServer,
+ pluginProvided: Boolean,
+ ex: Exception? = null,
+): String {
+ val username =
+ when (user.authMethod) {
+ SeerrAuthMethod.LOCAL,
+ SeerrAuthMethod.JELLYFIN,
+ -> user.username ?: stringResource(R.string.unknown)
+
+ SeerrAuthMethod.API_KEY -> stringResource(R.string.api_key)
+ }
+ val method =
+ when (user.authMethod) {
+ SeerrAuthMethod.LOCAL -> R.string.seerr_local_user
+ SeerrAuthMethod.JELLYFIN -> R.string.seerr_jellyfin_user
+ SeerrAuthMethod.API_KEY -> 0
+ }.let {
+ if (it != 0) " (${stringResource(it)})" else ""
+ }
+
+ val body =
+ """
+ ${stringResource(R.string.url)}: ${server.url}
+ ${stringResource(R.string.username)}: $username$method
+ """.trimIndent() +
+ if (pluginProvided) {
+ "\n\n${stringResource(R.string.managed_by_server)}"
+ } else {
+ ""
+ } +
+ if (ex != null) {
+ "\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}"
+ } else {
+ ""
+ }
+
+ return remember { body }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
index a21405c2..0e388213 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
@@ -36,10 +36,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
@@ -51,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
+import com.github.damontecres.wholphin.preferences.HdrFormatPreferences
import com.github.damontecres.wholphin.preferences.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
@@ -74,9 +77,11 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
+import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.setup.ReleaseNotes
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
-import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
+import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog
+import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus
@@ -98,6 +103,7 @@ fun PreferencesContent(
onFocus: (Int, Int) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
+ val resources = LocalResources.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
val firstFocusRequester = remember { FocusRequester() }
@@ -110,6 +116,11 @@ fun PreferencesContent(
var showPinFlow by remember { mutableStateOf(false) }
var showVersionDialog by remember { mutableStateOf(false) }
val players by viewModel.externalPlayers.collectAsState()
+ val serverSupported by viewModel.serverSupported.collectAsState()
+ val serverVersionMessage =
+ remember(resources, serverSupported) {
+ resources.serverSupportedMessage(serverSupported.second, serverSupported.first)
+ }
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrConnection by viewModel.seerrConnection.collectAsState()
@@ -155,6 +166,7 @@ fun PreferencesContent(
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences
+ PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences
}
val screenTitle =
when (preferenceScreenOption) {
@@ -165,6 +177,7 @@ fun PreferencesContent(
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings
+ PreferenceScreenOption.HDR_FORMATS -> R.string.hdr_formats
}
var visible by remember { mutableStateOf(false) }
@@ -224,6 +237,19 @@ fun PreferencesContent(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize(),
) {
+ serverVersionMessage?.let { message ->
+ item {
+ ListItem(
+ selected = false,
+ enabled = true,
+ onClick = {},
+ headlineContent = {
+ PreferenceTitle(message, color = MaterialTheme.colorScheme.error)
+ },
+ modifier = Modifier,
+ )
+ }
+ }
if (showUpdate) {
item {
val interactionSource = remember { MutableInteractionSource() }
@@ -259,6 +285,7 @@ fun PreferencesContent(
)
}
}
+
prefList.forEachIndexed { groupIndex, group ->
item {
Text(
@@ -475,10 +502,11 @@ fun PreferencesContent(
title = stringResource(pref.title),
onClick = {
seerrDialogMode =
- when (val conn = seerrConnection) {
+ when (val conn = seerrConnection.connection) {
is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error(
- conn.server.url,
+ conn.server,
+ conn.user,
conn.ex,
)
}
@@ -489,22 +517,20 @@ fun PreferencesContent(
is SeerrConnectionStatus.Success -> {
SeerrDialogMode.Remove(
- conn.current.server.url,
+ conn.current,
)
}
}
},
modifier = focusModifier,
summary =
- when (seerrConnection) {
+ when (seerrConnection.connection) {
is SeerrConnectionStatus.Error -> {
stringResource(R.string.voice_error_server)
}
SeerrConnectionStatus.NotConfigured -> {
- stringResource(
- R.string.add_server,
- )
+ stringResource(R.string.add_server)
}
is SeerrConnectionStatus.Success -> {
@@ -699,64 +725,15 @@ fun PreferencesContent(
)
}
}
- when (val mode = seerrDialogMode) {
- is SeerrDialogMode.Remove -> {
- ConfirmDialog(
- title = stringResource(R.string.remove_seerr_server),
- body = mode.serverUrl,
- onCancel = { seerrDialogMode = SeerrDialogMode.None },
- onConfirm = {
- seerrVm.removeServer()
- seerrDialogMode = SeerrDialogMode.None
- },
- )
- }
-
- SeerrDialogMode.Add -> {
- val currentUser by seerrVm.currentUser.collectAsState(null)
- val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
- val serverAddedMessage = stringResource(R.string.seerr_server_added)
- LaunchedEffect(status) {
- if (status == LoadingState.Success) {
- Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show()
- seerrDialogMode = SeerrDialogMode.None
- }
- }
- AddSeerServerDialog(
- currentUsername = currentUser?.name,
- status = status,
- onSubmit = seerrVm::submitServer,
- onResetStatus = seerrVm::resetStatus,
- onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
- )
- }
-
- is SeerrDialogMode.Error -> {
- val errorStr = stringResource(R.string.voice_error_server)
- val body =
- remember(mode) {
- """
- ${mode.serverUrl}
-
- $errorStr: ${mode.ex.localizedMessage}
- """.trimIndent()
- }
- ConfirmDialog(
- title = stringResource(R.string.remove_seerr_server),
- body = body,
- onCancel = { seerrDialogMode = SeerrDialogMode.None },
- onConfirm = {
- seerrVm.removeServer()
- seerrDialogMode = SeerrDialogMode.None
- },
- bodyColor = MaterialTheme.colorScheme.error,
- )
- }
-
- SeerrDialogMode.None -> {}
- }
}
+ SeerrDialog(
+ seerrDialogMode = seerrDialogMode,
+ seerrConnection = seerrConnection,
+ onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
+ seerrVm = seerrVm,
+ )
+
if (showQuickConnectDialog) {
val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending)
val successMessage = stringResource(R.string.quick_connect_success)
@@ -838,6 +815,7 @@ fun PreferencesPage(
PreferenceScreenOption.SCREENSAVER,
PreferenceScreenOption.SKIP_SEGMENTS,
PreferenceScreenOption.EXPERIMENTAL,
+ PreferenceScreenOption.HDR_FORMATS,
-> {
PreferencesContent(
initialPreferences,
@@ -857,18 +835,3 @@ data class CacheUsage(
val imageMemoryMax: Long,
val imageDiskUsed: Long,
)
-
-private sealed interface SeerrDialogMode {
- data object None : SeerrDialogMode
-
- data object Add : SeerrDialogMode
-
- data class Remove(
- val serverUrl: String,
- ) : SeerrDialogMode
-
- data class Error(
- val serverUrl: String,
- val ex: Exception,
- ) : SeerrDialogMode
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
index 200bac4e..2e0ce392 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
@@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.launchIO
+import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
@@ -33,6 +35,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
@@ -76,6 +79,23 @@ class PreferencesViewModel
val externalPlayers = MutableStateFlow>(emptyList())
+ val serverSupported =
+ serverRepository.currentServerFlow
+ .map {
+ val serverVersion = it?.serverVersion
+ serverVersion to
+ when {
+ serverVersion == null -> ServerVersionSupported.NOT_SUPPORTED
+ serverVersion < WholphinApplication.minimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED
+ serverVersion < WholphinApplication.newMinimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
+ else -> ServerVersionSupported.SUPPORTED
+ }
+ }.stateIn(
+ viewModelScope,
+ SharingStarted.Eagerly,
+ null to ServerVersionSupported.SUPPORTED,
+ )
+
init {
viewModelScope.launchIO {
val fakeIntent =
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt
index 81cc7c2a..bd14c38c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt
@@ -32,6 +32,7 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
+import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.CircularProgress
import org.jellyfin.sdk.model.api.PublicSystemInfo
@@ -177,7 +178,12 @@ fun ServerIconCard(
Icon(
imageVector = Icons.Default.Warning,
contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message,
- tint = MaterialTheme.colorScheme.errorContainer,
+ tint =
+ if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) {
+ AppColors.GoldenYellow
+ } else {
+ MaterialTheme.colorScheme.errorContainer
+ },
modifier = Modifier.size(cardSize * 0.3f),
)
Text(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt
index c25fe776..dd972f9b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt
@@ -11,6 +11,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
@@ -66,11 +67,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur
-import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.rememberInt
+import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
+import org.jellyfin.sdk.model.ServerVersion
import timber.log.Timber
@Composable
@@ -101,6 +103,8 @@ private fun SwitchServerContentInternal(
modifier: Modifier = Modifier,
) {
val resources = LocalResources.current
+ val focusRequesters =
+ remember(state.servers.size) { List(state.servers.size + 1) { FocusRequester() } }
var showAddServer by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(null) }
var focusedIndex by rememberInt(0)
@@ -144,10 +148,13 @@ private fun SwitchServerContentInternal(
val firstServerFocus = remember { FocusRequester() }
LaunchedEffect(state.loading) {
val state = state
- if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) {
- firstServerFocus.tryRequestFocus()
- } else if (state.loading == LoadingState.Pending) {
- firstServerFocus.tryRequestFocus()
+ when (state.loading) {
+ is LoadingState.Error,
+ LoadingState.Loading,
+ LoadingState.Pending,
+ -> Unit
+
+ LoadingState.Success -> focusRequesters.firstOrNull()?.tryRequestFocus()
}
}
LazyRow(
@@ -188,10 +195,7 @@ private fun SwitchServerContentInternal(
Modifier
.onFocusChanged {
if (it.isFocused) focusedIndex = index
- }.ifElse(
- index == 0,
- Modifier.focusRequester(firstServerFocus),
- ),
+ }.focusRequester(focusRequesters[index]),
)
}
// Add Server card - always rightmost
@@ -202,10 +206,7 @@ private fun SwitchServerContentInternal(
Modifier
.onFocusChanged {
if (it.isFocused) focusedIndex = -1
- }.ifElse(
- state.servers.isEmpty(),
- Modifier.focusRequester(firstServerFocus),
- ),
+ }.focusRequester(focusRequesters.last()),
)
}
}
@@ -235,20 +236,7 @@ private fun SwitchServerContentInternal(
.fillMaxWidth()
.align(Alignment.BottomCenter),
) { message ->
-
- Text(
- text = message ?: "",
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
- .align(Alignment.BottomCenter),
- )
+ SwitchErrorMessage(message ?: "")
}
// Delete server dialog
@@ -327,12 +315,12 @@ private fun AddServerDialog(
}
}
- val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
+ val buttonFocusRequester = remember { FocusRequester() }
// Default focus to first discovered server if available
- LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) {
- if (!showEnterAddress && state.discoveredServers.isNotEmpty()) {
- firstDiscoveredServerFocusRequester.tryRequestFocus()
+ LaunchedEffect(showEnterAddress) {
+ if (!showEnterAddress) {
+ buttonFocusRequester.tryRequestFocus()
}
}
@@ -379,12 +367,8 @@ private fun AddServerDialog(
key = { state.discoveredServers[it].url },
) { index ->
val server = state.discoveredServers[index]
- val focusRequester =
- if (index == 0) {
- firstDiscoveredServerFocusRequester
- } else {
- remember { FocusRequester() }
- }
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) { if (index == 0) focusRequester.tryRequestFocus() }
ListItem(
enabled = true,
@@ -491,21 +475,38 @@ fun serverErrorMessage(
server: ServerState?,
resources: Resources,
): String? =
- when {
- server?.status is ServerConnectionStatus.Error -> {
- server.status.message
- }
-
- server?.status is ServerConnectionStatus.Success &&
- server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> {
- resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}"
- }
-
- server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> {
- resources.getString(R.string.server_version_not_supported)
- }
-
- else -> {
- null
- }
+ if (server == null || server.status == ServerConnectionStatus.Pending) {
+ null
+ } else if (server.status is ServerConnectionStatus.Success) {
+ resources.serverSupportedMessage(
+ server.versionSupported,
+ server.status.systemInfo.version
+ ?.let { ServerVersion.fromString(it) },
+ )
+ } else if (server.status is ServerConnectionStatus.Error) {
+ server.status.message
+ } else {
+ resources.serverSupportedMessage(
+ server.versionSupported,
+ null,
+ )
}
+
+@Composable
+fun BoxScope.SwitchErrorMessage(message: String?) {
+ message?.let {
+ Text(
+ text = message,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
+ .align(Alignment.BottomCenter),
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt
index 53190665..00beccb3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt
@@ -97,6 +97,8 @@ class SwitchServerViewModel
result.systemInfo.version?.let { ServerVersion.fromString(it) }
if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) {
ServerVersionSupported.NOT_SUPPORTED
+ } else if (serverVersion < WholphinApplication.newMinimumServerVersion) {
+ ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
} else {
ServerVersionSupported.SUPPORTED
}
@@ -307,4 +309,5 @@ enum class ServerVersionSupported {
SUPPORTED,
NOT_SUPPORTED,
UNKNOWN,
+ NOT_SUPPORTED_IN_FUTURE,
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt
index 86cf718d..cfe70890 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt
@@ -31,7 +31,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -50,6 +49,7 @@ import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.dimAndBlur
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.serverSupportedMessage
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
@@ -183,37 +183,15 @@ fun SwitchUserContent(
modifier = Modifier.fillMaxWidth(),
)
}
- when (state.serverVersionSupported) {
- ServerVersionSupported.SUPPORTED -> {}
-
- ServerVersionSupported.NOT_SUPPORTED,
- ServerVersionSupported.UNKNOWN,
- -> {
- val resources = LocalResources.current
- val message =
- remember(resources) {
- if (state.serverVersion.isNotNullOrBlank()) {
- resources.getString(R.string.server_version_not_supported) + ": ${state.serverVersion}"
- } else {
- resources.getString(R.string.server_version_not_supported) + ": " +
- resources.getString(R.string.unknown)
- }
- }
- Text(
- text = message,
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
- .align(Alignment.BottomCenter),
+ val resources = LocalResources.current
+ val message =
+ remember(resources, state.serverVersionSupported) {
+ resources.serverSupportedMessage(
+ state.serverVersionSupported,
+ state.serverVersion,
)
}
- }
+ SwitchErrorMessage(message)
}
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt
index e1a6dd96..b11b0b19 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt
@@ -300,16 +300,18 @@ class SwitchUserViewModel
knownUsers + publicUsers
}
- private suspend fun checkServerVersion(): Pair {
+ private suspend fun checkServerVersion(): Pair {
val api = jellyfin.createApi(server.url)
val systemInfo by api.systemApi.getPublicSystemInfo()
val serverVersion = systemInfo.version?.let { ServerVersion.fromString(it) }
return if (serverVersion == null) {
- systemInfo.version to ServerVersionSupported.UNKNOWN
+ serverVersion to ServerVersionSupported.UNKNOWN
} else if (serverVersion < WholphinApplication.minimumServerVersion) {
- systemInfo.version to ServerVersionSupported.NOT_SUPPORTED
+ serverVersion to ServerVersionSupported.NOT_SUPPORTED
+ } else if (serverVersion < WholphinApplication.newMinimumServerVersion) {
+ serverVersion to ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE
} else {
- systemInfo.version to ServerVersionSupported.SUPPORTED
+ serverVersion to ServerVersionSupported.SUPPORTED
}
}
@@ -341,7 +343,7 @@ data class SwitchUserState(
// LoadingState for while adding/switching users
val switchUserState: LoadingState = LoadingState.Pending,
val loginAttempts: Int = 0,
- val serverVersion: String? = null,
+ val serverVersion: ServerVersion? = null,
val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN,
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt
index 59c9b2ae..5faccc05 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt
@@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun AddSeerrServerApiKey(
+ initialUrl: String,
onSubmit: (url: String, apiKey: String) -> Unit,
status: LoadingState,
modifier: Modifier = Modifier,
@@ -55,12 +56,18 @@ fun AddSeerrServerApiKey(
.padding(16.dp)
.wrapContentSize(),
) {
- var url by remember { mutableStateOf(seerrUrlPrefill()) }
+ var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) }
var apiKey by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ LaunchedEffect(Unit) {
+ if (url.isBlank()) {
+ focusRequester.tryRequestFocus()
+ } else {
+ passwordFocusRequester.tryRequestFocus()
+ }
+ }
Text(
text = stringResource(R.string.enter_url_api_key),
style = MaterialTheme.typography.titleMedium,
@@ -153,6 +160,7 @@ fun AddSeerrServerApiKey(
@Composable
fun AddSeerrServerUsername(
+ initialUrl: String,
onSubmit: (url: String, username: String, password: String) -> Unit,
username: String,
status: LoadingState,
@@ -167,14 +175,22 @@ fun AddSeerrServerUsername(
.padding(16.dp)
.wrapContentSize(),
) {
- var url by remember { mutableStateOf(seerrUrlPrefill()) }
+ var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) }
var username by remember { mutableStateOf(username) }
var password by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
- LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
+ LaunchedEffect(Unit) {
+ val focusRequesterToUse =
+ when {
+ url.isNotNullOrBlank() && username.isNotNullOrBlank() -> passwordFocusRequester
+ url.isNotNullOrBlank() -> usernameFocusRequester
+ else -> focusRequester
+ }
+ focusRequesterToUse.tryRequestFocus()
+ }
Text(
text = stringResource(R.string.username_or_password),
style = MaterialTheme.typography.titleMedium,
@@ -318,6 +334,7 @@ fun AddSeerrServerUsername(
private fun AddSeerrServerUsernamePreview() {
WholphinTheme {
AddSeerrServerUsername(
+ initialUrl = "",
onSubmit = { string: String, string1: String, string2: String -> },
username = "test",
status = LoadingState.Pending,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt
index bb38aabb..5be93b83 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt
@@ -1,27 +1,161 @@
package com.github.damontecres.wholphin.ui.setup.seerr
+import android.widget.Toast
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
+import com.github.damontecres.wholphin.data.model.SeerrServer
+import com.github.damontecres.wholphin.data.model.SeerrUser
+import com.github.damontecres.wholphin.services.CurrentSeerr
+import com.github.damontecres.wholphin.services.SeerrState
import com.github.damontecres.wholphin.ui.components.BasicDialog
+import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.components.ScrollableDialog
+import com.github.damontecres.wholphin.ui.preferences.rememberSeerrDialogBody
import com.github.damontecres.wholphin.util.LoadingState
+@Composable
+fun SeerrDialog(
+ seerrDialogMode: SeerrDialogMode,
+ seerrConnection: SeerrState,
+ onDismissRequest: () -> Unit,
+ seerrVm: SwitchSeerrViewModel = hiltViewModel(),
+) {
+ val context = LocalContext.current
+ when (val mode = seerrDialogMode) {
+ is SeerrDialogMode.Remove -> {
+ val body = rememberSeerrDialogBody(mode.current.user, mode.current.server, seerrConnection.pluginProvided)
+ if (seerrConnection.pluginProvided) {
+ ScrollableDialog(
+ onDismissRequest = onDismissRequest,
+ ) {
+ item {
+ Text(body)
+ }
+ }
+ } else {
+ ConfirmDialog(
+ title = stringResource(R.string.remove_seerr_server),
+ body = body,
+ onCancel = onDismissRequest,
+ onConfirm = {
+ seerrVm.removeServer()
+ onDismissRequest.invoke()
+ },
+ elevation = 3.dp,
+ )
+ }
+ }
+
+ SeerrDialogMode.Add -> {
+ val currentUser by seerrVm.currentUser.collectAsState(null)
+ val state by seerrVm.state.collectAsState()
+ val serverAddedMessage = stringResource(R.string.seerr_server_added)
+ LaunchedEffect(state.serverConnectionStatus) {
+ if (state.serverConnectionStatus == LoadingState.Success) {
+ Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show()
+ onDismissRequest.invoke()
+ }
+ }
+ when (state.loading) {
+ is LoadingState.Error,
+ LoadingState.Loading,
+ LoadingState.Pending,
+ -> {
+ BasicDialog(
+ onDismissRequest = onDismissRequest,
+ ) {
+ LoadingPage(Modifier.padding(16.dp))
+ }
+ }
+
+ LoadingState.Success -> {
+ AddSeerServerDialog(
+ initialUrl = state.initialUrl,
+ currentUsername = currentUser?.name,
+ status = state.serverConnectionStatus,
+ onSubmit = seerrVm::submitServer,
+ onResetStatus = seerrVm::resetStatus,
+ onDismissRequest = onDismissRequest,
+ )
+ }
+ }
+ }
+
+ is SeerrDialogMode.Error -> {
+ val body = rememberSeerrDialogBody(mode.user, mode.server, seerrConnection.pluginProvided, mode.ex)
+ if (seerrConnection.pluginProvided) {
+ ConfirmDialog(
+ title = stringResource(R.string.error),
+ body = body,
+ onCancel = onDismissRequest,
+ onConfirm = {
+ seerrVm.retryServerFromPlugin()
+ onDismissRequest.invoke()
+ },
+ bodyColor = MaterialTheme.colorScheme.error,
+ elevation = 3.dp,
+ confirmStringRes = R.string.retry,
+ )
+ } else {
+ ConfirmDialog(
+ title = stringResource(R.string.remove_seerr_server),
+ body = body,
+ onCancel = onDismissRequest,
+ onConfirm = {
+ seerrVm.removeServer()
+ onDismissRequest.invoke()
+ },
+ bodyColor = MaterialTheme.colorScheme.error,
+ elevation = 3.dp,
+ )
+ }
+ }
+
+ SeerrDialogMode.None -> {}
+ }
+}
+
+sealed interface SeerrDialogMode {
+ data object None : SeerrDialogMode
+
+ data object Add : SeerrDialogMode
+
+ data class Remove(
+ val current: CurrentSeerr,
+ ) : SeerrDialogMode
+
+ data class Error(
+ val server: SeerrServer,
+ val user: SeerrUser,
+ val ex: Exception,
+ ) : SeerrDialogMode
+}
+
@Composable
fun AddSeerServerDialog(
currentUsername: String?,
+ initialUrl: String?,
status: LoadingState,
onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
onResetStatus: () -> Unit,
@@ -42,6 +176,7 @@ fun AddSeerServerDialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
AddSeerrServerUsername(
+ initialUrl = initialUrl ?: "",
onSubmit = { url, username, password ->
onSubmit.invoke(url, username, password, auth)
},
@@ -57,6 +192,7 @@ fun AddSeerServerDialog(
onDismissRequest = { authMethod = null },
) {
AddSeerrServerApiKey(
+ initialUrl = initialUrl ?: "",
onSubmit = { url, apiKey ->
onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY)
},
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt
index 773f6c74..2fde3eae 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt
@@ -3,11 +3,13 @@ package com.github.damontecres.wholphin.ui.setup.seerr
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService
+import com.github.damontecres.wholphin.services.ServerPluginApi
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
@@ -15,6 +17,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -28,11 +31,29 @@ class SwitchSeerrViewModel
private val seerrServerRepository: SeerrServerRepository,
private val seerrService: SeerrService,
private val serverRepository: ServerRepository,
+ private val serverPluginApi: ServerPluginApi,
) : ViewModel() {
val currentUser = serverRepository.currentUserFlow
val currentSeerrServer = seerrServerRepository.currentServer
- val serverConnectionStatus = MutableStateFlow(LoadingState.Pending)
+ private val _state = MutableStateFlow(SwitchSeerrState())
+ val state: StateFlow = _state
+
+ init {
+ if (serverRepository.serverPluginInstalled.value) {
+ viewModelScope.launchIO {
+ try {
+ serverPluginApi.fetchSeerrSettings()?.serverUrl?.let { serverUrl ->
+ _state.update { it.copy(initialUrl = serverUrl) }
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error fetching seerr settings")
+ } finally {
+ _state.update { it.copy(loading = LoadingState.Success) }
+ }
+ }
+ }
+ }
fun submitServer(
url: String,
@@ -41,13 +62,21 @@ class SwitchSeerrViewModel
authMethod: SeerrAuthMethod,
) {
viewModelScope.launchIO {
- serverConnectionStatus.update { LoadingState.Loading }
+ _state.update { it.copy(serverConnectionStatus = LoadingState.Loading) }
val urls =
try {
createUrls(url)
} catch (ex: IllegalArgumentException) {
showToast(context, "Invalid URL")
- serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) }
+ _state.update {
+ it.copy(
+ serverConnectionStatus =
+ LoadingState.Error(
+ "Invalid URL",
+ ex,
+ ),
+ )
+ }
return@launchIO
}
Timber.v("Urls to try: %s", urls)
@@ -98,7 +127,7 @@ class SwitchSeerrViewModel
)
}
}
- serverConnectionStatus.update { LoadingState.Success }
+ _state.update { it.copy(serverConnectionStatus = LoadingState.Success) }
} else {
val message =
results
@@ -107,7 +136,7 @@ class SwitchSeerrViewModel
"$url - ${s?.localizedMessage}"
}.joinToString("\n")
showToast(context, "Could not connect")
- serverConnectionStatus.update { LoadingState.Error(message) }
+ _state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) }
}
}
}
@@ -121,11 +150,31 @@ class SwitchSeerrViewModel
}
}
+ fun retryServerFromPlugin() {
+ viewModelScope.launchIO {
+ val settings = serverPluginApi.fetchSeerrSettings()
+ if (settings != null) {
+ val result = seerrServerRepository.usePluginProvidedServer(settings)
+ if (!result) {
+ showToast(context, context.getString(R.string.voice_error_server))
+ }
+ } else {
+ seerrServerRepository.clear()
+ }
+ }
+ }
+
fun resetStatus() {
- serverConnectionStatus.update { LoadingState.Pending }
+ _state.update { it.copy(serverConnectionStatus = LoadingState.Pending) }
}
}
+data class SwitchSeerrState(
+ val loading: LoadingState = LoadingState.Pending,
+ val serverConnectionStatus: LoadingState = LoadingState.Pending,
+ val initialUrl: String? = null,
+)
+
fun createUrls(url: String): List {
val urls = mutableListOf()
if (url.startsWith("http://") || url.startsWith("https://")) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt
index e74ffdf2..8172b049 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt
@@ -20,6 +20,8 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.ScreensaverService
+import com.github.damontecres.wholphin.services.StreamChoiceReason
+import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.PhotoItemFields
import com.github.damontecres.wholphin.ui.launchIO
@@ -141,13 +143,16 @@ class SlideshowViewModel
),
)
serverRepository.currentUser?.let { user ->
+
val filter =
- playbackEffectDao
- .getPlaybackEffect(
- user.rowId,
- slideshowSettings.parentId,
- BaseItemKind.PHOTO_ALBUM,
- )?.videoFilter
+ slideshowSettings.parentId?.let {
+ playbackEffectDao
+ .getPlaybackEffect(
+ user.rowId,
+ slideshowSettings.parentId,
+ BaseItemKind.PHOTO_ALBUM,
+ )?.videoFilter
+ }
if (filter != null) {
Timber.v("Got filter for album %s", slideshowSettings.parentId)
albumImageFilter = filter
@@ -229,12 +234,21 @@ class SlideshowViewModel
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
ChosenStreams(
itemPlayback = null,
- plc = null,
+ audioStc = emptyList(),
+ subtitleStc = emptyList(),
itemId = image.id,
source = source,
videoStream = video,
- audioStream = audio,
- subtitleStream = null,
+ audioStream =
+ StreamChoiceResult(
+ audio,
+ StreamChoiceReason.Unknown,
+ ),
+ subtitleStream =
+ StreamChoiceResult(
+ null,
+ StreamChoiceReason.Unknown,
+ ),
subtitlesDisabled = false,
)
}
@@ -430,7 +444,7 @@ class SlideshowViewModel
fun saveGalleryFilter() {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
val vf = _imageFilter.value
- if (vf != null) {
+ if (slideshowSettings.parentId != null) {
albumImageFilter = vf
serverRepository.currentUser?.let { user ->
playbackEffectDao
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt
new file mode 100644
index 00000000..31f4f6fa
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt
@@ -0,0 +1,48 @@
+package com.github.damontecres.wholphin.ui.util
+
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
+import com.github.damontecres.wholphin.data.model.GetItemsFilter
+import com.github.damontecres.wholphin.ui.data.SortAndDirection
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.util.BlockingList
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.serializer.toUUIDOrNull
+import timber.log.Timber
+import kotlin.random.Random
+
+/**
+ * Navigate to a random item in the given list
+ */
+suspend fun goToRandom(
+ navigateTo: (Destination) -> Unit,
+ parentId: String,
+ filter: GetItemsFilter,
+ sortAndDirection: SortAndDirection,
+ recursive: Boolean,
+ list: BlockingList,
+) {
+ val randomIndex = Random.nextInt(list.size)
+ val random = list.getBlocking(randomIndex)
+
+ Timber.d("Got random item #%s: %s", randomIndex, random?.id)
+ if (random != null) {
+ val destination =
+ if (random.type == BaseItemKind.PHOTO) {
+ Destination.Slideshow(
+ parentId = parentId.toUUIDOrNull(),
+ index = randomIndex,
+ filter =
+ CollectionFolderFilter(
+ filter = filter,
+ ),
+ sortAndDirection = sortAndDirection,
+ recursive = recursive,
+ startSlideshow = false,
+ )
+ } else {
+ random.destination()
+ }
+ navigateTo(destination)
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
index 750ba711..2f98fccc 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt
@@ -22,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.GetProgramsDto
+import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest
@@ -401,6 +402,25 @@ object GetArtistsHandler : RequestHandler {
): Response = api.artistsApi.getArtists((request))
}
+@Serializable
+object GetAlbumArtistsHandler : RequestHandler {
+ override fun prepare(
+ request: GetAlbumArtistsRequest,
+ startIndex: Int,
+ limit: Int,
+ enableTotalRecordCount: Boolean,
+ ): GetAlbumArtistsRequest =
+ request.copy(
+ startIndex = startIndex,
+ limit = limit,
+ )
+
+ override suspend fun execute(
+ api: ApiClient,
+ request: GetAlbumArtistsRequest,
+ ): Response = api.artistsApi.getAlbumArtists(request)
+}
+
val requestSerializersModule =
SerializersModule {
polymorphic(Any::class) {
@@ -416,6 +436,7 @@ val requestSerializersModule =
subclass(GetStudiosRequest::class)
subclass(GetRecordingsRequest::class)
subclass(GetLiveTvChannelsRequest::class)
- subclass(GetArtistsHandler::class)
+ subclass(GetArtistsRequest::class)
+ subclass(GetAlbumArtistsRequest::class)
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt
index c334967f..d83ae269 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt
@@ -1,7 +1,7 @@
package com.github.damontecres.wholphin.util
import android.content.Context
-import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.MostRecentServer
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.DeviceModule
import com.google.auto.service.AutoService
@@ -13,6 +13,7 @@ import org.acra.sender.ReportSender
import org.acra.sender.ReportSenderException
import org.acra.sender.ReportSenderFactory
import org.jellyfin.sdk.Jellyfin
+import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.clientLogApi
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
@@ -39,26 +40,10 @@ class CrashReportSender : ReportSender {
errorContent: CrashReportData,
) {
Timber.v("Attempting to send crash report")
- val prefs = ServerRepository.getServerSharedPreferences(context)
- val serverUrl = prefs.getString(ServerRepository.SERVER_URL_KEY, null)
- val accessToken = prefs.getString(ServerRepository.ACCESS_TOKEN_KEY, null)
- if (serverUrl != null && accessToken != null) {
- try {
- val okHttpClient =
- OkHttpClient
- .Builder()
- .build()
- val okHttpFactory = OkHttpFactory(okHttpClient)
- val api =
- createJellyfin {
- this.context = context
- clientInfo = AppModule.clientInfo(context)
- deviceInfo = DeviceModule.deviceInfo(context)
- apiClientFactory = okHttpFactory
- socketConnectionFactory = okHttpFactory
- minimumServerVersion = Jellyfin.minimumVersion
- }.createApi(baseUrl = serverUrl, accessToken = accessToken)
-
+ try {
+ val recentServer = AppModule.mostRecentServerProvider(context).get()
+ val api = recentServer.createApi(context)
+ if (api != null) {
val obj = JSONObject()
for ((key, value) in errorContent.toMap()) {
obj.put(key, value)
@@ -75,13 +60,32 @@ class CrashReportSender : ReportSender {
""".trimIndent() + jsonStr,
).content.fileName
- Timber.i("Sent report to $serverUrl, filename=$filename")
+ Timber.i("Sent report to %s, filename=%s", api.baseUrl, filename)
}
- } catch (ex: Exception) {
- throw ReportSenderException("Exception while sending crash report", ex)
+ } else {
+ throw ReportSenderException("Could not find valid server and/or credentials to use")
}
- } else {
- throw ReportSenderException("Could not find valid server and/or credentials to use")
+ } catch (ex: Exception) {
+ throw ReportSenderException("Exception while sending crash report", ex)
}
}
}
+
+private fun MostRecentServer.createApi(context: Context): ApiClient? =
+ (this as? MostRecentServer.ServerAndUser)?.let {
+ val okHttpClient =
+ OkHttpClient
+ .Builder()
+ .build()
+ val okHttpFactory = OkHttpFactory(okHttpClient)
+ val api =
+ createJellyfin {
+ this.context = context
+ clientInfo = AppModule.clientInfo(context)
+ deviceInfo = DeviceModule.deviceInfo(context)
+ apiClientFactory = okHttpFactory
+ socketConnectionFactory = okHttpFactory
+ minimumServerVersion = Jellyfin.minimumVersion
+ }.createApi(baseUrl = serverUrl, accessToken = accessToken)
+ api
+ }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt
new file mode 100644
index 00000000..b4f8e15f
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt
@@ -0,0 +1,71 @@
+@file:OptIn(UnstableApi::class)
+
+package com.github.damontecres.wholphin.util
+
+import androidx.annotation.OptIn
+import androidx.media3.common.Format
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.util.UnstableApi
+import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
+import com.github.damontecres.wholphin.preferences.enabled
+import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
+
+/**
+ * The one place which decides whether Dolby Vision profile 7 is converted to profile 8.1 on this
+ * device, and how a converted track is described.
+ *
+ * Profile 7 is HDR10 compatible HEVC plus two extra NAL unit types: 62 carries the RPU metadata and
+ * 63 the enhancement layer. A decoder which only lists profile 8 takes such a stream but does not
+ * switch the display into Dolby Vision for it, so the picture comes out as the HDR10 base layer.
+ * Rewriting the RPU into its profile 8.1 form and dropping the enhancement layer produces the
+ * single layer stream that decoder does switch for. Nothing is re-encoded.
+ *
+ * The conversion has two halves which have to agree:
+ * - [DolbyVisionProfile7Filter] rewrites the access units on their way into the decoder;
+ * - [DolbyVisionProfile7ExtractorsFactory] rewrites the codec string of the track, because
+ * media3 picks the decoder by `Format.codecs` and a decoder which does not list profile 7 would
+ * otherwise never be chosen for it.
+ *
+ * Both are switched on by [isWanted], as is direct play of profile 7 in the device profile.
+ */
+object DolbyVisionProfile7Conversion {
+ /**
+ * True when both the user has enabled the setting and this device is one it helps: its Dolby
+ * Vision decoder lists profile 8 but not profile 7. A device which decodes profile 7 itself
+ * is left alone, since it can use the enhancement layer the conversion would throw away, and
+ * a device without a profile 8 decoder has nothing to handle the converted stream
+ */
+ fun isWanted(
+ experimental: ExperimentalPreferences,
+ mediaTest: MediaCodecCapabilitiesTest,
+ ): Boolean =
+ experimental.enabled { convertDolbyVisionProfile7 } &&
+ !mediaTest.supportsHevcDolbyVisionEL() &&
+ mediaTest.supportsHevcDolbyVisionProfile8()
+
+ /** Whether [format] is a Dolby Vision profile 7 video track, in either sample entry form. */
+ fun isProfile7(format: Format): Boolean {
+ val codecs = format.codecs ?: return false
+ return MimeTypes.VIDEO_DOLBY_VISION == format.sampleMimeType &&
+ (codecs.startsWith(CODECS_PREFIX_DVHE_07) || codecs.startsWith(CODECS_PREFIX_DVH1_07))
+ }
+
+ /**
+ * The [format] as the converted stream should be advertised: `dvhe.07.06` becomes
+ * `dvhe.08.06`, the level and the sample entry prefix stay. Encrypted tracks are left as they
+ * are: their samples reach the decoder through the secure path the filter does not touch, so
+ * advertising them as converted would be a lie.
+ */
+ fun advertiseAsProfile8(format: Format): Format =
+ if (isProfile7(format) && format.drmInitData == null) {
+ format.buildUpon().setCodecs(toProfile8(format.codecs!!)).build()
+ } else {
+ format
+ }
+
+ /** `dvhe.07.06` → `dvhe.08.06`. The caller checks [isProfile7] first. */
+ fun toProfile8(codecs: String): String = codecs.substring(0, 5) + "08" + codecs.substring(7)
+
+ private const val CODECS_PREFIX_DVHE_07 = "dvhe.07"
+ private const val CODECS_PREFIX_DVH1_07 = "dvh1.07"
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt
new file mode 100644
index 00000000..632f46c3
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt
@@ -0,0 +1,79 @@
+@file:OptIn(markerClass = [UnstableApi::class])
+
+package com.github.damontecres.wholphin.util
+
+import android.net.Uri
+import androidx.annotation.OptIn
+import androidx.media3.common.C
+import androidx.media3.common.Format
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.extractor.Extractor
+import androidx.media3.extractor.ExtractorOutput
+import androidx.media3.extractor.ExtractorsFactory
+import androidx.media3.extractor.ForwardingExtractor
+import androidx.media3.extractor.ForwardingExtractorOutput
+import androidx.media3.extractor.ForwardingExtractorsFactory
+import androidx.media3.extractor.ForwardingTrackOutput
+import androidx.media3.extractor.TrackOutput
+import timber.log.Timber
+
+/**
+ * Advertises Dolby Vision profile 7 video tracks as profile 8.1, which is what
+ * [DolbyVisionProfile7Filter] turns their samples into before the decoder sees them.
+ *
+ * media3 chooses the decoder by `Format.codecs`, and a decoder which lists profile 8 but not
+ * profile 7 answers "not supported" to `dvhe.07`; with decoder fallback on, playback then lands on
+ * whichever decoder comes first in the list, with a warning that the format exceeds its
+ * capabilities. Rewriting the codec string at the extractor routes the track to the profile 8
+ * decoder, and it also sets `MediaFormat.KEY_PROFILE` right, which media3 derives from the same
+ * string.
+ *
+ * Only the codec string changes. Sample data is not touched here: the two convenience overloads of
+ * [TrackOutput.sampleData] go straight to the delegate, which is exactly what is wanted.
+ */
+class DolbyVisionProfile7ExtractorsFactory(
+ delegate: ExtractorsFactory,
+) : ForwardingExtractorsFactory(delegate) {
+ override fun createExtractors(): Array = wrap(super.createExtractors())
+
+ override fun createExtractors(
+ uri: Uri,
+ responseHeaders: Map>,
+ ): Array = wrap(super.createExtractors(uri, responseHeaders))
+
+ private fun wrap(extractors: Array): Array =
+ Array(extractors.size) { Profile8AdvertisingExtractor(extractors[it]) }
+}
+
+private class Profile8AdvertisingExtractor(
+ delegate: Extractor,
+) : ForwardingExtractor(delegate) {
+ override fun init(output: ExtractorOutput) {
+ super.init(Profile8AdvertisingExtractorOutput(output))
+ }
+}
+
+private class Profile8AdvertisingExtractorOutput(
+ output: ExtractorOutput,
+) : ForwardingExtractorOutput(output) {
+ override fun track(
+ id: Int,
+ type: Int,
+ ): TrackOutput {
+ val trackOutput = super.track(id, type)
+ return if (type == C.TRACK_TYPE_VIDEO) Profile8AdvertisingTrackOutput(trackOutput) else trackOutput
+ }
+}
+
+/** Rewrites the codec string of every profile 7 format it is handed; everything else passes unchanged. */
+internal class Profile8AdvertisingTrackOutput(
+ delegate: TrackOutput,
+) : ForwardingTrackOutput(delegate) {
+ override fun format(format: Format) {
+ val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format)
+ if (advertised !== format) {
+ Timber.i("Advertising Dolby Vision track %s as %s for conversion", format.codecs, advertised.codecs)
+ }
+ super.format(advertised)
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt
new file mode 100644
index 00000000..7cf50622
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt
@@ -0,0 +1,313 @@
+@file:OptIn(markerClass = [UnstableApi::class])
+
+package com.github.damontecres.wholphin.util
+
+import androidx.annotation.OptIn
+import androidx.media3.common.util.UnstableApi
+import com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi
+import timber.log.Timber
+import java.nio.Buffer
+import java.nio.ByteBuffer
+
+/**
+ * Rewrites the Dolby Vision RPU of one access unit, in place. The production implementation is
+ * libdovi, as packaged by ExoplayerHdrUtils; tests substitute their own.
+ *
+ * Both methods take a direct buffer whose access unit starts at index 0 and is [size] bytes long.
+ * That is the shape the native code reads: it takes the buffer's address and capacity, not its
+ * position and limit.
+ */
+interface RpuRewriter {
+ /**
+ * The Dolby Vision profile of the RPU (NAL unit type 62) in the access unit, or 0 when there is
+ * none it can parse.
+ */
+ fun profileOf(
+ frame: ByteBuffer,
+ size: Int,
+ ): Int
+
+ /**
+ * Rewrites the profile 7 RPU in the access unit into its profile 8.1 form and returns the new
+ * size of the access unit. The bytes after the RPU move to stay contiguous, so the buffer's
+ * capacity must leave room past [size] for an RPU which comes out longer than it went in.
+ */
+ fun convertToProfile8(
+ frame: ByteBuffer,
+ size: Int,
+ ): Int
+}
+
+/** [RpuRewriter] on libdovi. Constructing it loads the native library, which throws a [LinkageError] on an ABI it does not ship for. */
+class LibDoviRpuRewriter : RpuRewriter {
+ private val libDovi = LibDovi()
+
+ override fun profileOf(
+ frame: ByteBuffer,
+ size: Int,
+ ): Int = libDovi.getFrameInfo(frame, size)?.doviProfile ?: 0
+
+ override fun convertToProfile8(
+ frame: ByteBuffer,
+ size: Int,
+ ): Int = libDovi.processHevcFrame(frame, size, DOVI_TRANSFORM_CONVERT_TO_PROFILE_8, false)
+
+ private companion object {
+ /** `doviTransform` value of `LibDovi.processHevcFrame` for the profile 7 → 8.1 rewrite. */
+ const val DOVI_TRANSFORM_CONVERT_TO_PROFILE_8 = 1
+ }
+}
+
+/**
+ * Converts Dolby Vision profile 7 access units to profile 8.1 on their way into the decoder.
+ *
+ * Profile 7 carries its RPU in NAL unit type 62 and its enhancement layer in type 63, after the
+ * picture's own NAL units. The conversion drops every type 63 unit and has libdovi rewrite the RPU
+ * into the profile 8.1 form, which is what a profile 8 decoder switches the display into Dolby
+ * Vision for. The enhancement layer goes because profile 8.1 is single layer by definition: a
+ * decoder handed a dual layer stream under that name keeps it out of the Dolby Vision pipeline and
+ * shows the HDR10 base layer, which is the very symptom this filter exists to fix.
+ *
+ * Every access unit is judged on its own. One without an RPU, or whose RPU is not profile 7, is
+ * left exactly as it came, and so is one libdovi refuses. The rewrite happens in a scratch buffer
+ * and is copied back only once it has succeeded and fits, so a refused access unit reaches the
+ * decoder untouched rather than half rewritten.
+ *
+ * The codec string of the track has to say profile 8 for the decoder to be chosen at all; that is
+ * [DolbyVisionProfile7ExtractorsFactory]'s job. An instance belongs to one video renderer and is
+ * called from its playback thread only.
+ */
+class DolbyVisionProfile7Filter(
+ private val newRewriter: () -> RpuRewriter = { LibDoviRpuRewriter() },
+) : BitstreamFilter {
+ private var rewriter: RpuRewriter? = null
+ private var rewriterUnavailable = false
+
+ /** The access unit as it arrived, for scanning: a heap array is much faster to walk than absolute gets on a direct buffer. */
+ private var staging = ByteArray(0)
+
+ /** The access unit without its enhancement layer, where libdovi rewrites the RPU. Direct, because the native code needs its address. */
+ private var scratch: ByteBuffer? = null
+
+ private var convertedCount = 0
+ private var passedThroughCount = 0
+ private var refusedCount = 0
+
+ override fun filter(
+ data: ByteBuffer,
+ offset: Int,
+ size: Int,
+ ): Int {
+ if (!startsWithStartCode(data, offset, size)) {
+ return size
+ }
+ val rewriter = rewriter() ?: return size
+
+ val staging = staging(size)
+ window(data, offset, size).get(staging, 0, size)
+ if (!carriesRpu(staging, size)) {
+ passedThroughCount++
+ return size
+ }
+
+ val scratch = scratch(size + RPU_GROWTH_HEADROOM_BYTES)
+ val kept = copyWithoutEnhancementLayer(staging, size, scratch)
+ val converted =
+ try {
+ if (rewriter.profileOf(scratch, kept) != DOLBY_VISION_PROFILE_7) {
+ passedThroughCount++
+ return size
+ }
+ rewriter.convertToProfile8(scratch, kept)
+ } catch (e: RuntimeException) {
+ refuse("the RPU rewrite threw", e)
+ return size
+ }
+ if (converted <= 0 || converted > size) {
+ refuse("the RPU rewrite returned $converted bytes for a $size byte access unit", null)
+ return size
+ }
+
+ window(data, offset, converted).put(window(scratch, 0, converted))
+ convertedCount++
+ return converted
+ }
+
+ private fun rewriter(): RpuRewriter? {
+ if (rewriterUnavailable) {
+ return null
+ }
+ return rewriter ?: try {
+ newRewriter().also { rewriter = it }
+ } catch (e: LinkageError) {
+ // No libdovi for this ABI. Playback goes on exactly as it did without the filter.
+ Timber.w(e, "libdovi is not available on this device, Dolby Vision profile 7 is left as it is")
+ rewriterUnavailable = true
+ null
+ }
+ }
+
+ private fun refuse(
+ reason: String,
+ e: Throwable?,
+ ) {
+ if (refusedCount == 0) {
+ Timber.w(e, "Leaving a Dolby Vision profile 7 access unit as it is: %s", reason)
+ }
+ refusedCount++
+ }
+
+ private fun staging(size: Int): ByteArray {
+ if (staging.size < size) {
+ staging = ByteArray(maxOf(size, INITIAL_CAPACITY_BYTES))
+ }
+ return staging
+ }
+
+ private fun scratch(capacity: Int): ByteBuffer {
+ val current = scratch
+ val buffer =
+ if (current == null || current.capacity() < capacity) {
+ ByteBuffer.allocateDirect(maxOf(capacity, INITIAL_CAPACITY_BYTES)).also { scratch = it }
+ } else {
+ current
+ }
+ (buffer as Buffer).clear()
+ return buffer
+ }
+
+ override fun toString(): String =
+ "Dolby Vision profile 7 → 8.1 ($convertedCount access units converted, " +
+ "$passedThroughCount passed through, $refusedCount refused)"
+
+ private companion object {
+ const val DOLBY_VISION_PROFILE_7 = 7
+
+ /** HEVC NAL unit type carrying the Dolby Vision RPU. */
+ const val NAL_UNIT_TYPE_RPU = 62
+
+ /** HEVC NAL unit type carrying the Dolby Vision enhancement layer picture. */
+ const val NAL_UNIT_TYPE_ENHANCEMENT_LAYER = 63
+
+ /** Big enough for a UHD access unit without regrowing on every early frame. */
+ const val INITIAL_CAPACITY_BYTES = 512 * 1024
+
+ /**
+ * Room past the access unit for an RPU which comes out of the rewrite longer than it went
+ * in. Dropping the enhancement layer usually frees far more, but a profile 7 stream whose
+ * enhancement layer the extractor never delivered has none to free.
+ */
+ const val RPU_GROWTH_HEADROOM_BYTES = 4 * 1024
+
+ private fun startsWithStartCode(
+ data: ByteBuffer,
+ offset: Int,
+ size: Int,
+ ): Boolean {
+ if (size < 4) {
+ return false
+ }
+ return data.get(offset) == 0.toByte() &&
+ data.get(offset + 1) == 0.toByte() &&
+ (
+ data.get(offset + 2) == 1.toByte() ||
+ (data.get(offset + 2) == 0.toByte() && data.get(offset + 3) == 1.toByte())
+ )
+ }
+
+ /** Whether the Annex B access unit in `[0, length)` of [source] carries an RPU. */
+ private fun carriesRpu(
+ source: ByteArray,
+ length: Int,
+ ): Boolean {
+ var unit = 0
+ while (unit < length) {
+ val payload = unit + startCodeLength(source, unit)
+ if (payload >= length) {
+ return false
+ }
+ if (nalUnitType(source[payload]) == NAL_UNIT_TYPE_RPU) {
+ return true
+ }
+ unit = nextStartCode(source, payload, length)
+ }
+ return false
+ }
+
+ /**
+ * Copies the Annex B access unit in `[0, length)` of [source] into [target] from index 0,
+ * leaving out every enhancement layer NAL unit, and returns the number of bytes copied.
+ */
+ fun copyWithoutEnhancementLayer(
+ source: ByteArray,
+ length: Int,
+ target: ByteBuffer,
+ ): Int {
+ var unit = 0
+ var written = 0
+ while (unit < length) {
+ val payload = unit + startCodeLength(source, unit)
+ if (payload >= length) {
+ // A start code with nothing behind it. Not something to interpret; kept as it came.
+ target.put(source, unit, length - unit)
+ written += length - unit
+ break
+ }
+ val next = nextStartCode(source, payload, length)
+ if (nalUnitType(source[payload]) != NAL_UNIT_TYPE_ENHANCEMENT_LAYER) {
+ target.put(source, unit, next - unit)
+ written += next - unit
+ }
+ unit = next
+ }
+ return written
+ }
+
+ /**
+ * A view of `[offset, offset + length)` of [data] with a position and limit of its own, so
+ * that bulk copies leave the buffer's untouched. The position and limit calls go through
+ * [Buffer]: the [ByteBuffer] overrides which return a ByteBuffer are API 34, and `minSdk` is 23.
+ */
+ private fun window(
+ data: ByteBuffer,
+ offset: Int,
+ length: Int,
+ ): ByteBuffer {
+ val view = data.duplicate()
+ val buffer: Buffer = view
+ buffer.clear()
+ buffer.position(offset)
+ buffer.limit(offset + length)
+ return view
+ }
+
+ /** nal_unit_type is the 6 bits after forbidden_zero_bit, H.265 section 7.3.1.2. */
+ private fun nalUnitType(header: Byte): Int = (header.toInt() shr 1) and 0x3F
+
+ /** Length of the start code at [at], which the caller knows is one: 3 or 4 bytes. */
+ private fun startCodeLength(
+ data: ByteArray,
+ at: Int,
+ ): Int = if (data[at + 2] == 1.toByte()) 3 else 4
+
+ /**
+ * Index of the next Annex B start code at or after [from], or [limit] if there is none. A
+ * fourth leading zero byte counts as part of the start code, which is the form media3's
+ * extractors emit; three byte codes are walked all the same.
+ */
+ private fun nextStartCode(
+ data: ByteArray,
+ from: Int,
+ limit: Int,
+ ): Int {
+ var i = from
+ while (i + 2 < limit) {
+ if (data[i] == 0.toByte() && data[i + 1] == 0.toByte() && data[i + 2] == 1.toByte()) {
+ return if (i > from && data[i - 1] == 0.toByte()) i - 1 else i
+ }
+ i++
+ }
+ return limit
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt
index 5c5004ac..f1384a27 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt
@@ -12,7 +12,6 @@ import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
-import org.jellyfin.sdk.model.api.VideoRangeType
import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
@@ -71,10 +70,11 @@ fun createDeviceProfile(
downMixAudio: Boolean,
assDirectPlay: Boolean,
pgsDirectPlay: Boolean,
- dolbyVisionELDirectPlay: Boolean,
decodeAv1: Boolean,
jellyfinTenEleven: Boolean,
preferAc3ForSurround: Boolean,
+ forceEnabledHdr: Set,
+ forceDisabledHdr: Set,
maxResolution: Int,
) = buildDeviceProfile {
val allowedAudioCodecs =
@@ -148,9 +148,9 @@ fun createDeviceProfile(
videoCodec(Codec.Video.H264)
if (preferAc3ForSurround) {
- audioCodec(*allowedAudioCodecs)
- } else {
audioCodec(Codec.Audio.AC3)
+ } else {
+ audioCodec(*allowedAudioCodecs)
}
copyTimestamps = false
@@ -467,60 +467,56 @@ fun createDeviceProfile(
// / HDR exclude list
- // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK
val unsupportedRangeTypesAv1 =
buildSet {
- if (jellyfinTenEleven) add("DOVIInvalid")
+ if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName)
if (!decodeAv1) {
if (!supportsAV1DolbyVision) {
- add(VideoRangeType.DOVI.serialName)
- if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName)
- if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus")
+ add(WholphinVideoRangeType.DOVI.serialName)
+ if (!supportsAV1HDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName)
+ if (jellyfinTenEleven && !supportsAV1HDR10Plus) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName)
}
if (!supportsAV1HDR10Plus) {
- add(VideoRangeType.HDR10_PLUS.serialName)
+ add(WholphinVideoRangeType.HDR10_PLUS.serialName)
- if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName)
+ if (!mediaTest.supportsAV1HDR10()) add(WholphinVideoRangeType.HDR10.serialName)
}
}
- }
+ } - forceEnabledHdr + forceDisabledHdr
- // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK
val unsupportedRangeTypesHevc =
buildSet {
- if (jellyfinTenEleven) add("DOVIInvalid")
+ if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName)
if (!supportsHevcDolbyVisionEL) {
- if (!dolbyVisionELDirectPlay) {
- if (jellyfinTenEleven) {
- add("DOVIWithEL")
- if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus")
+ if (jellyfinTenEleven) {
+ add(WholphinVideoRangeType.DOVI_WITH_EL.serialName)
+ if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) {
+ add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName)
}
}
if (!supportsHevcDolbyVision) {
- add(VideoRangeType.DOVI.serialName)
- if (!supportsHevcHDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName)
+ add(WholphinVideoRangeType.DOVI.serialName)
+ if (!supportsHevcHDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName)
if (jellyfinTenEleven && !supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) {
- add(
- "DOVIWithHDR10Plus",
- )
+ add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName)
}
}
}
if (!supportsHevcHDR10Plus) {
- add(VideoRangeType.HDR10_PLUS.serialName)
- if (!supportsHevcHDR10) add(VideoRangeType.HDR10.serialName)
+ add(WholphinVideoRangeType.HDR10_PLUS.serialName)
+ if (!supportsHevcHDR10) add(WholphinVideoRangeType.HDR10.serialName)
}
if (jellyfinTenEleven && KnownDefects.hevcDoviHdr10PlusBug) {
- add("DOVIWithHDR10Plus")
- add("DOVIWithELHDR10Plus")
+ add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName)
+ add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName)
}
- }
+ } - forceEnabledHdr + forceDisabledHdr
// Display
// Note: The codec profiles use a workaround to create correct behavior
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt
index ab4de03e..a769c400 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt
@@ -209,6 +209,15 @@ class MediaCodecCapabilitiesTest(
) &&
supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC)
+ // Checks for single-layer Dolby Vision Profile 8, which the Profile 7 conversion produces
+ fun supportsHevcDolbyVisionProfile8(): Boolean =
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
+ hasDecoder(
+ MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION,
+ DolbyVisionProfiles.Profile8,
+ CodecProfileLevel.DolbyVisionLevelHd24,
+ )
+
fun supportsHevcHDR10(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasDecoder(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt
new file mode 100644
index 00000000..bd49a6c4
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt
@@ -0,0 +1,27 @@
+package com.github.damontecres.wholphin.util.profile
+
+import org.jellyfin.sdk.model.api.VideoRangeType
+
+/**
+ * Complete list of video range types, including ones not yet present in the pinned Jellyfin SDK's
+ * [VideoRangeType] enum (added server-side in Jellyfin 10.11).
+ *
+ * TODO Remove this and use [VideoRangeType] directly once the SDK is upgraded to a version built against Jellyfin 10.11+
+ */
+enum class WholphinVideoRangeType(
+ val serialName: String,
+) {
+ UNKNOWN(VideoRangeType.UNKNOWN.serialName),
+ SDR(VideoRangeType.SDR.serialName),
+ HDR10(VideoRangeType.HDR10.serialName),
+ HLG(VideoRangeType.HLG.serialName),
+ DOVI(VideoRangeType.DOVI.serialName),
+ DOVI_WITH_HDR10(VideoRangeType.DOVI_WITH_HDR10.serialName),
+ DOVI_WITH_HLG(VideoRangeType.DOVI_WITH_HLG.serialName),
+ DOVI_WITH_SDR(VideoRangeType.DOVI_WITH_SDR.serialName),
+ HDR10_PLUS(VideoRangeType.HDR10_PLUS.serialName),
+ DOVI_INVALID("DOVIInvalid"),
+ DOVI_WITH_EL("DOVIWithEL"),
+ DOVI_WITH_EL_HDR10_PLUS("DOVIWithELHDR10Plus"),
+ DOVI_WITH_HDR10_PLUS("DOVIWithHDR10Plus"),
+}
diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto
index 0c3b5cb3..55553166 100644
--- a/app/src/main/proto/WholphinDataStore.proto
+++ b/app/src/main/proto/WholphinDataStore.proto
@@ -54,10 +54,15 @@ message PlaybackOverrides{
bool direct_play_ass = 3 [deprecated = true];
bool direct_play_pgs = 4;
MediaExtensionStatus media_extensions_enabled = 5;
- bool direct_play_dolby_vision_e_l = 6;
+ bool direct_play_dolby_vision_e_l = 6 [deprecated = true];
bool decode_av1 = 7;
AssPlaybackMode ass_playback_mode = 8;
int32 max_resolution = 9;
+ HdrOverrideMode dovi_profile_5_override = 10;
+ HdrOverrideMode dovi_profile_7_override = 11;
+ HdrOverrideMode dovi_profile_8_override = 12;
+ HdrOverrideMode hdr10_override = 13;
+ HdrOverrideMode hdr10_plus_override = 14;
}
message PlaybackPreferences {
@@ -225,18 +230,24 @@ message MusicPreferences {
bool show_backdrop = 4;
}
+enum HdrOverrideMode{
+ HDR_OVERRIDE_AUTO = 0;
+ HDR_OVERRIDE_ENABLE = 1;
+ HDR_OVERRIDE_DISABLE = 2;
+}
+
message ExperimentalPreferences {
bool enabled = 1;
bool video_tunneling_enabled = 2;
bool prefer_ac3_surround = 3;
bool disable_audio_offload = 4;
bool prefer_dolby_vision_over_hdr10_plus = 5;
+ bool convert_dolby_vision_profile_7 = 6;
}
message AppPreferences {
- // The currently signed in server and user IDs, mostly for restoring a session
- string current_server_id = 1;
- string current_user_id = 2;
+ string current_server_id = 1 [deprecated = true];
+ string current_user_id = 2 [deprecated = true];
PlaybackPreferences playback_preferences = 3;
HomePagePreferences home_page_preferences = 4;
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index f4523d02..7170a274 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -719,4 +719,44 @@
Von Wiedergabeliste entfernen
Anmeldeinformationen abgelaufen, bitte erneut anmelden
Du bist aktuell nicht berechtigt auf den Server zuzugreifen
+ Dolby Vision gegenüber HDR10+ bevorzugen
+ Entfernt HDR10+ Metadaten von Dolby Vision Videos für TVs, welche die Dateien sonst als HDR10+ abspielen würden
+ Maximale Auflösung vor dem Transkodieren
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+
+ - %1$d Folge
+ - %1$d Folgen
+
+ Am besten bewertet
+ HDR Formate
+ HDR10
+ HDR10+
+ Dolby Vision Profile 5
+ Dolby Vision Profile 7
+ Dolby Vision Profile 8
+ Benötigte Server Version: %1$s
+ Benutzer
+ Standard vom Server laden
+ Nur auf diesem Gerät speichern
+ Standard
+ Geräte spezifisch
+ Server Benutzerprofil
+ Server Standard
+ Benutzer Webclient
+ Vom Server verwaltet
+ Fehler
+ Server Version wird bald nicht mehr unterstützt
+ Festgelegt von %1$s
+ Dolby Vision Profile 7 zu 8.1 konvertieren
+ Für Geräte, die zwar Dolby Vision Profil 8, aber nicht Profil 7 dekodieren können, wird das zweischichtige Profil 7 vor der Wiedergabe in das einschichtige Profil 8.1 umgewandelt und dann direkt abgespielt
+ Interpreten Quelle
+ Reihen
+ Aktivieren erzwingen
+ Deaktivieren erzwingen
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 696ad927..714ae611 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -741,4 +741,19 @@
Eliminar de la lista de reproducción
Credenciales caducadas, vuelve a iniciar sesión
No tienes autorización para acceder al servidor en este momento
+ Preferir Dolby Vision sobre HDR10+
+ Elimina los metadatos HDR10+ de vídeos en Dolby Vision para televisores que, de lo contrario, los reproducirían como HDR10+
+ Resolución máxima antes de transcodificar
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+
+ - %1$d Episodio
+ - %1$d Episodios
+ - %1$d Episodios
+
diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml
index 8fc29c91..2008bf2c 100644
--- a/app/src/main/res/values-et/strings.xml
+++ b/app/src/main/res/values-et/strings.xml
@@ -719,4 +719,44 @@
Eemalda esitusloendist
Autentimiseandmed on aegunud, palun logi uuesti sisse
Sul pole hetkel õigusi ligipääsuks sellele serverile
+ HDR10+ asemel eelista Dolby Visionit
+ Kui teler muul juhul kasutaks esituseks HDR10+ vormingut, siis eemaldab HDR10+ metateabe Dolby Visioni videotest
+ Maksimaalne resolutsioon enne kodeeringu muutmist
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+
+ - %1$d osa
+ - %1$d osa
+
+ Parima hinnanguga
+ HDR-vormingud
+ HDR10
+ HDR10+
+ Dolby Vision Profile 5
+ Dolby Vision Profile 7
+ Dolby Vision Profile 8
+ Vajalik serveriversioon: %1$s
+ Serveriversioon ei saa lähitulevikus olema toetatud
+ Kasutaja
+ Sai
+ Vaikimisi laadi serverist
+ Salvesta vaid siia seadmesse
+ Vaikimisi
+ Seadmekohane
+ Kasutajaprofiil serveris
+ Vaikimisi väärtus serveris
+ Kasutaja veebiklient
+ Valija: %1$s
+ Jõusta sundkorras
+ Lõpeta sundkorras jõustamine
+ Hallatud serveri poolt
+ Viga
+ Teisenda Dolby Vision Profile 7 → 8.1
+ Muudab kahekihilise „Profile 7“ enne dekooderit ühekihiliseks profiiliks 8.1 ja esitab seda seadmetel otse, mille dekooder toetab profiili 8, kuid mitte profiili 7
+ Esitaja allikas
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 898f8a10..85603d28 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -724,4 +724,62 @@
Livre audio
Version du serveur non prise en charge
Apparaît dans
+ %1$dh %2$ds
+ S%1$d E%2$d
+ S%1$d E%2$d-E%3$d
+ S%1$02dE%2$02d
+ S%1$02dE%2$02d-E%3$02d
+ E%1$d
+ Saison %1$d
+ Désactiver le délestage matériel audio
+ La désactivation du déchargement du décodage audio peut résoudre les problèmes de blocage de la lecture sur certains appareils
+ Préférez Dolby Vision à HDR10+
+ Supprime les métadonnées HDR10+ des vidéos Dolby Vision, pour les téléviseurs qui, sans cela, les liraient en HDR10+
+ Écran en mode veille après un certain délai
+ Pourcentage de réduction de luminosité
+ Écran sombre
+ Albums photos
+ Aucun favori trouvé !
+ Supprimer de la liste de lecture
+ Vos identifiants ont expiré. Veuillez vous reconnecter
+ Vous n\'êtes pas autorisé(e) à accéder au serveur pour le moment
+ Résolution maximale avant le transcodage
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+
+ - %1$d Épisode
+ - %1$d Épisodes
+ - %1$d Épisode
+
+ Les mieux notés
+ Formats HDR
+ HDR10
+ HDR10+
+ Dolby Vision Profile 5
+ Dolby Vision Profile 7
+ Dolby Vision Profile 8
+ Activation forcée
+ Désactivation forcée
+ La version du serveur ne sera bientôt plus prise en charge
+ Version requise du serveur : %1$s
+ Sélectionné par %1$s
+ Utilisateur
+ Séries
+ Charger les paramètres par défaut depuis le serveur
+ Enregistrer uniquement sur cet appareil
+ Défaut
+ Spécifique à l\'appareil
+ Profil utilisateur du serveur
+ Paramètres par défaut du serveur
+ Client Web utilisateur
+ Convertir le profil Dolby Vision 7 en 8.1
+ Convertit le profil 7 double couche en profil 8.1 simple couche avant le décodeur et le lit directement, pour les appareils dont le décodeur prend en charge le profil 8 mais pas le profil 7
+ Source de l\'artiste
+ Géré par le serveur
+ Erreur
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 64d8c3b1..f078a5f7 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -25,7 +25,7 @@
Rendező
Letiltva
Felfedezett szerverek
- Letöltés & Frissítés
+
Letöltés folyamatban…
Engedélyezve
Adja meg a szerver IP-t vagy URL-t
@@ -43,7 +43,7 @@
Kiegészítő
Műfajok
Ugrás a sorozathoz
- Ugrás ide:
+ Ugrás ide
Lejátszási gombok elrejtése
Hibakeresési infó elrejtése
Elrejt
@@ -95,7 +95,7 @@
Újraindítás
Folytatás
Mentés
- Keresés & Letöltés
+
Keresés
Keresés…
Szerver kiválasztása
@@ -121,7 +121,7 @@
Váltás
Legjobbra értékelt, nem megnézett
- - Előzetesek
+ - Előzetes
- Előzetesek
DVR Időzítés
@@ -138,6 +138,7 @@
Videók
Nézze élőben
+ - %1$d éves
- %1$d éves
Lejátszás átalakítással
@@ -159,8 +160,8 @@
- Egyebek
- - Színfalak mögött
- - Színfalak mögött
+ - A kulisszák mögött
+ - A kulisszák mögött
- Főcímdal
@@ -200,19 +201,19 @@
- %s letöltés
- - %s letöltés
+ - %s letöltések
- %d óra
- - %d óra
+ - %d órák
- %d elem
- - %d elem
+ - %d elemek
- %d másodperc
- - %d másodperc
+ - %d másodpercek
%1$s-kor ér véget
Nem található szerver
@@ -234,7 +235,7 @@
Tartalom alapértelmezett méretezése
Frissítés telepítése
Telepített verzió
- Elemek száma soronként a kezdőlapon
+ A kezdőlap sorainak maximális elemszáma
Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve
Navigációs menü elemeinek testreszabása
Kattintson az oldalak közti váltáshoz
@@ -303,10 +304,10 @@
PIN-kód eltávolítása
Nézetbeállítások
Oszlopok
- Helyköz
+ Térköz
Képarány
- Részletek mutatása
- Kép típusa
+ Részletek megjelenítése
+ Képtípus
Vendégszereplők
Szegély mérete
Tartalomhoz igazodó képfrissítési gyakoriság
@@ -354,10 +355,10 @@
Felirat késleltetése
Háttér stílusa
Adja meg a szerver címét
- Folytatás & Következő menü összevonása
+
Lejátszás backend
Kép-gyorsítótár mérete (MB)
- Stábtagok
+
Navigációs menü oldalainak váltása fókusznál
Lejátszás felülbírálatai
Felbontásváltás
@@ -372,4 +373,275 @@
DTS
Csak kiegészítő felirat
Sávválasztás törlése
+ %1$dó %2$dp %3$dmp
+ %1$dó %2$dp
+ %1$dó %2$dmp
+ %1$dp %2$dmp
+ %1$dó
+ %1$dp
+ %1$dmp
+ Felhasználói profilbeállítások a szerverről
+ Hangalapú keresés
+ Indítás
+ Keresés hanggal
+ Feldolgozás
+ A megszakításhoz nyomd meg a Vissza gombot
+ Hangfelvételi hiba
+ Hangfelismerési hiba
+ Mikrofonengedély szükséges
+ Hálózati hiba
+ Hálózati időtúllépés
+ Nem sikerült felismerni a beszédet
+ A hangfelismerés foglalt
+ Szerverhiba
+ Nem észlelhető beszéd
+ Ismeretlen hiba
+ Nem sikerült elindítani a hangfelismerést
+ A hangfelismerés időtúllépés miatt megszakadt
+ Újrapróbálás
+ Előzetesek
+ %1$d. évad
+ Egyéb
+ Werkfilmek
+ Főcímdalok
+ Főcímvideók
+ Klipek
+ Törölt jelenetek
+ Interjúk
+ Jelenetek
+ Minták
+ Rövid werkfilmek
+ Rövidfilmek
+ Kedvenc %s
+
+ - %s nap
+ - %s napok
+
+
+ - %s perc
+ - %s percek
+
+
+ - Film
+ - Filmek
+
+
+ - Sorozat
+ - Sorozatok
+
+
+ - Személy
+ - Személyek
+
+ Bejelentkezés szükséges a profil használatához
+ [0–9]
+ Hardveres hangfeldolgozás kikapcsolása
+ A hardveres hangdekódolás kikapcsolása egyes eszközökön megoldhatja a lejátszás közbeni lefagyásokat
+ Összesített keresési találatok
+ Az összes találat megjelenítése egyetlen listában
+ Találatok megjelenítése kategóriánként
+ Hangalapú keresés gomb megjelenítése
+ Látható
+ Rejtett
+ Találatok
+ Dolby Vision 7-es profil közvetlen lejátszása
+ Figyelmen kívül hagyja az eszközkompatibilitás ellenőrzését
+ A Dolby Vision előnyben részesítése a HDR10+-szal szemben
+ Eltávolítja a HDR10+ metaadatokat a Dolby Vision-videókból azoknál a tévéknél, amelyek egyébként HDR10+-ként játszanák le őket
+ Felfedezés
+ Kérés
+ Függőben
+ Seerr-integráció
+ Seerr-szerver eltávolítása
+ Seerr-szerver hozzáadva
+ Gyors csatlakozás
+ Engedélyezd egy másik eszköznek a bejelentkezést a fiókodba
+ Add meg a gyors csatlakozási kódot
+ A kódnak 6 számjegyből kell állnia
+ Az eszköz engedélyezése sikeres
+ Jelszó
+ Felhasználónév
+ URL
+ Népszerű most
+ Hamarosan megjelenő filmek
+ Hamarosan induló sorozatok
+ Kérés 4K-ban
+ AV1 szoftveres dekódolása
+ Az MPV mostantól az alapértelmezett lejátszó, kivéve HDR-tartalmak esetén. \nEzt a beállításokban módosíthatod.
+ HDR-feliratok stílusa
+ Képalapú feliratok átlátszatlansága
+ Fényerő
+ Kontraszt
+ Telítettség
+ Színárnyalat
+ Mentés albumhoz
+ Diavetítés indítása
+ Diavetítés leállítása
+ Az elején
+ Nincs több fénykép
+ Forgatás balra
+ Forgatás jobbra
+ Nagyítás
+ Kicsinyítés
+ Diavetítés időtartama
+ Videók lejátszása diavetítés közben
+ Médiaadat-napló küldése a szervernek
+ Korlátlan
+ A „Következő” listában megjelenő napok maximális száma
+ Albumok
+ Előadók
+ Dalok
+ Ugrás az előadóhoz
+ Most játszott tartalom
+ Azonnali mix
+ Ugrás az albumhoz
+ Hozzáadás a lejátszási sorhoz
+ Sor hozzáadása
+ Műfajok %1$s
+ Nemrég megjelent %1$s
+ Magasság
+ Alkalmazás az összes sorra
+ Kezdőlap testreszabása
+ Kezdőlap sorai
+ Betöltés a szerver felhasználói profiljából
+ Mentés a szerver felhasználói profiljába
+ Betöltés a webes kliensből
+ Sorozat képének használata
+ Sor hozzáadása ehhez: %1$s
+ Felülírod a szerveren lévő beállításokat?
+ Felülírod a helyi beállításokat?
+ Epizódokhoz
+ Javaslatok ehhez: %1$s
+ Az összes kártya méretének növelése
+ Az összes kártya méretének csökkentése
+ Bélyegképek használata
+ Beállítások mentve
+ Kijelző-beállítások
+ Beépített előbeállítások az összes sor gyors formázásához
+ Sorok és képek kiválasztása a kezdőlapon
+
+ Térhatású hang használata AC3 formátumban
+ Kezdés ennyi idő múlva
+ Animálás
+ Maximális korhatár
+ Nincs felső korlát
+ Minden korosztály számára
+ %s éves korig
+ Képernyővédő
+ Képernyővédő beállítások
+ Képernyővédő elindítása
+ Időtartam
+ Fotók
+ Típusok felvétele
+ Mely elemtípusokhoz jelenjenek meg képek
+ Illesztés
+ Kivágás
+ Kitöltés
+ Szélesség kitöltése
+ Magasság kitöltése
+ Wholphin alapértelmezett beállítása
+ Wholphin kompakt elrendezés
+ A sorozat miniatűrképei
+ Az epizódok miniatűrképei
+ Legalacsonyabb
+ Alacsony
+ Közepes
+ Magas
+ Teljes
+ Lila
+ Narancs
+ Élénk kék
+ Fekete
+ Barna
+ Kihagyás
+ Automatikus átugrás
+ Rákérdezés a kihagyásra
+ Az FFmpeg használata csak akkor, ha nincs beépített dekóder
+ Az FFmpeg előnyben részesítése a beépített dekóderekkel szemben
+ FFmpeg-dekóderek használatának tiltása
+ A lejátszás végén
+ HDR-formátumok
+ HDR10
+ HDR10+
+ Dolby Vision 5-ös profil
+ Dolby Vision 7-es profil
+ Dolby Vision 8-as profil
+ Kényszerített engedélyezés
+ Kényszerített letiltás
+ Piros
+ Zöld
+ Kék
+ Elmosódás
+ A stáblista és a zárójelenetek alatt
+ Soha
+ Fehér
+ Világosszürke
+ Sötétszürke
+ Sárga
+ Cián
+ Magenta
+ Körvonal
+ Árnyék
+ Sortörés
+ Az MPV előnyben részesítése
+ Külső lejátszó
+ Az ExoPlayer használata HDR-lejátszáshoz
+ Poszter (2:3)
+ 16:9
+ 4:3
+ Négyzet (1:1)
+ Elsődleges
+ Bélyegkép
+ Rács
+ Lista
+ Összefoglaló lista
+ Dinamikus színű kép
+ Csak kép
+
+ API-kulcs
+ Bejelentkezés a Seerr-szerverre
+ Jellyfin-felhasználó
+ Helyi felhasználó
+
+ Nem található távoli felirat
+ Jelenlegi
+ Az alkalmazáson belüli képernyővédő használata
+ Biztosan törölni szeretnéd ezt az elemet?
+ Médiakezelési lehetőségek megjelenítése
+ Keresés %s
+ Színész
+ Zeneszerző
+ Író
+ Vendégszereplő
+ Producer
+ Karmester
+ Dalszövegíró
+ Rendezés
+ Mérnök
+ Hangmérnök
+ Alkotó
+ Művész
+ Album előadója
+ Album
+ Népszerű dalok
+ Lejátszás következőként
+ Videóklipek
+ Dalszövegek
+ Dalszöveg elrejtése
+ Dalszöveg megjelenítése
+ Eltávolítás a lejátszási sorból
+ Albumborító megjelenítése
+ Vizuális effektusok megjelenítése
+ Háttér megjelenítése
+ Lejátszás másként?
+ A jelenleg lejátszott hang vizualizálásához engedélyezni kell a hangrögzítést.
+ Folytatás
+ Összes kijelölése
+ Típusok különválasztása
+ A címek logóinak előnyben részesítése
+ A Wholphin frissült a %s verzióra
+ Eltávolítás a „Folytatás” listából
+ Sorozat eltávolítása a „Következő epizód” listából
+ A „Következő epizód” listából eltávolított sorozatok megtekintése
+ További információk
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index d6f20d4b..623ef596 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -694,4 +694,20 @@
Redupkan layar
Album foto
Favorit tidak ditemukan!
+
+ - %1$d episode
+
+ Utamakan Dolby Vision dibanding HDR10+
+ Hapus metadata HDR10+ dari video Dolby Vision, untuk TV yang dapat memutarnya sebagai HDR10+
+ Hapus dari playlist
+ Kredensial kedaluwarsa, silakan login kembali
+ Kamu tidak diizinkan mengakses server saat ini
+ Resolusi maksimum sebelum transkode
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index fb5f37ce..be2f53e3 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -741,4 +741,42 @@
Rimuovi dalla playlist
Credenziali scadute, effettua di nuovo l’accesso
Non sei autorizzato ad accedere al server in questo momento
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+
+ - %1$d Episodio
+ - %1$d Episodi
+ - %1$d Episodi
+
+ Preferisci Dolby Vision a HDR10+
+ Rimuove i metadati HDR10+ dai video Dolby Vision, per le TV che altrimenti li riproducono come HDR10+
+ Risoluzione massima prima della trascodifica
+ HDR10
+ HDR10+
+ I più votati
+ Formati HDR
+ Dolby Vision Profilo 5
+ Dolby Vision Profilo 7
+ Dolby Vision Profilo 8
+ Forza abilitazione
+ Forza disabilitazione
+ La versione del server non sarà più supportata a breve
+ Versione del server richiesta: %1$s
+ Serie
+ Errore
+ Utente
+ Carica predefinito dal server
+ Salva solo su questo dispositivo
+ Predefinito
+ Specifico del dispositivo
+ Profilo utente del server
+ Predefinito del server
+ Client web utente
+ Gestito dal server
+ Scelto da %1$s
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index fe4ce431..d05db022 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -220,7 +220,7 @@
- בגיל שנתיים
- בגיל %1$d
- נגן עם המרת קידוד
+ נגן עם המרת־קידוד
פרטי המדיה
הצג שעון
סדר שידור הפרקים
@@ -613,7 +613,7 @@
התנהגות דילוג
עבור פתיחים, כתוביות סיום וכו\'
נגן עם
- המרת קידוד
+ המרת־קידוד
NAL
AVC
SDR
@@ -738,4 +738,22 @@
עונה %1$02d פרקים %2$02d-%3$02d
פרק %1$d
עונה %1$d
+ העדפת Dolby Vision על פני HDR10+
+ מחיקת מטא-נתונים של HDR10+ ממדיה מסוג Dolby Vision עבור טלוויזיות שמעדיפות לנגן HDR10+ במצב זה
+ מחיקה מרשימת־השמעה
+ פרטי ההתחברות פגו, נא להתחבר מחדש
+ אינך מורשה לגשת לשרת בשלב זה
+ רזולוציה מירבית לפני המרת־קידוד
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+
+ - פרק %1$d
+ - %1$d פרקים
+ - %1$d פרקים
+
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
new file mode 100644
index 00000000..045e125f
--- /dev/null
+++ b/app/src/main/res/values-ja/strings.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 83247c34..3bb8567e 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -763,4 +763,46 @@
S%1$02d O%2$02d-%3$02d
Wyłącz sprzętowe wsparcie dźwięku
Wyłączenie sprzętowego wsparcie dźwięku może naprawić błąd z zatrzymaniem się odtwarzania na niektórych urządzeniach
+ 8K
+ 4K
+ 1080p
+ 720p
+ 576p
+ 480p
+ 360p
+
+ - %1$d odcinek
+ - %1$d odcinki
+ - %1$d odcinków
+ - %1$d odcinków
+
+ Maksymalna rozdzielczość przed transkodowaniem
+ Preferuj Dolby Vision nad HDR10+
+ Usuwa metadane HDR10+ z filmów Dolby Vision, dla telewizorów, które w przeciwnym razie odtwarzają je jako HDR10+
+ Najlepiej oceniane
+ Formaty HDR
+ HDR10
+ HDR10+
+ Dolby Vision P5
+ Dolby Vision P7
+ Dolby Vision P8
+ Wymuś włączenie
+ Wymuś wyłączenie
+ Wersja serwera wkrótce przestanie być wspierana
+ Wymagana wersja serwera: %1$s
+ Wybrane przez %1$s
+ Użytkownik
+ Seriale
+ Załaduj domyślne z serwera
+ Zapisuj tylko na tym urządzeniu
+ Domyślnie
+ Zależne od urządzenia
+ Profil użytkownika na serwerze
+ Domyślnie z serwera
+ Użytkownik klienta webowego
+ Konwertuj Dolby Vision 7 do 8.1
+ W przypadku urządzeń, których dekoder obsługuje profil 8, ale nie obsługuje profilu 7, system przekształca dwuwarstwowy profil 7 na jednowarstwowy profil 8.1 przed przekazaniem go do dekodera, umożliwiając bezpośrednie odtwarzanie
+ Źródło artysty
+ Zarządzane przez serwer
+ Błąd
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index d65ac309..f65e53af 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -741,4 +741,44 @@
Remover da lista de reprodução
As credenciais expiraram. Inicie sessão novamente
Não tem autorização para aceder ao servidor neste momento
+ Mais votados
+ Formato HDR
+ HDR10
+ HDR10+
+ Dolby Vision Profile 5
+ Dolby Vision Profile 7
+ Dolby Vision Profile 8
+ Ativação forçada
+ Desativação forçada
+ Preferir Dolby Vision sobre HDR10+
+ Remove os metadados HDR10+ dos vídeos em Dolby Vision para televisores que, de outra forma, os reproduziriam como HDR10+
+ Converte Dolby Vision Profile 7 para 8.1
+ Converte o Perfil 7 de camada dupla no Perfil 8.1 de camada única antes do descodificador e reproduz-o diretamente, para dispositivos cujo descodificador suporta o Perfil 8, mas não o Perfil 7
+ A versão do servidor deixará de ser suportada em breve
+ Versão do servidor necessária: %1$s
+ Resolução máxima antes da transcodificação
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+
+ - %1$d Episódio
+ - %1$d Episódios
+ - %1$d Episódios
+
+ Escolhido por %1$s
+ Utilizador
+ Séries
+ Carregar as predefinições do servidor
+ Guardar apenas neste dispositivo
+ Padrão
+ Específico do dispositivo
+ Perfil de utilizador do servidor
+ Padrão do servidor
+ Utilizador do cliente web
+ Gerido pelo servidor
+ Erro
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index f7cc6e4c..ca59ccb6 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -763,4 +763,43 @@
Убрать из плейлиста
Срок действия учётных данных истёк. Пожалуйста, войдите снова
У вас нет доступа к серверу в данный момент
+ С высоким рейтингом
+ Форматы HDR
+ HDR10
+ HDR10+
+ Dolby Vision, профиль 5
+ Dolby Vision, профиль 7
+ Dolby Vision, профиль 8
+ Принудительно включить
+ Принудительно отключить
+ Приоритет Dolby Vision над HDR10+
+ Удаляет метаданные HDR10+ из видео Dolby Vision для телевизоров, которые иначе воспроизводят такие видео в формате HDR10+
+ Поддержка этой версии сервера скоро прекратится
+ Требуемая версия сервера: %1$s
+ Максимальное разрешение без перекодирования
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+
+ - %1$d эпизод
+ - %1$d эпизода
+ - %1$d эпизодов
+ - %1$d эпизодов
+
+ Источник выбора: %1$s
+ Пользователь
+ Сериал
+ Загрузить настройки по умолчанию с сервера
+ Сохранить только на этом устройстве
+ По умолчанию
+ Настройки этого устройства
+ Профиль пользователя на сервере
+ Настройки по умолчанию на сервере
+ Веб-клиент пользователя
+ Управляется сервером
+ Ошибка
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
index bc78a1f8..50d2edd7 100644
--- a/app/src/main/res/values-sk/strings.xml
+++ b/app/src/main/res/values-sk/strings.xml
@@ -763,4 +763,20 @@
Odstrániť z playlistu
Platnosť prihlasovacích údajov vypršala, prihláste sa znova
Momentálne nemáte oprávnenie na prístup k serveru
+ Preferovať Dolby Vision pred HDR10+
+ Odstraňuje HDR10+ metadáta z Dolby Vision videí, aby ich televízory namiesto HDR10+ prehrávali v režime Dolby Vision
+ Maximálne rozlíšenie pred prekódovaním
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+
+ - %1$d epizóda
+ - %1$d epizódy
+ - %1$d epizód
+ - %1$d epizód
+
diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml
index 3df78be2..4244a0df 100644
--- a/app/src/main/res/values-sr/strings.xml
+++ b/app/src/main/res/values-sr/strings.xml
@@ -1,4 +1,134 @@
O aplikaciji
+ Aktivni Snimci
+ Omiljeni
+ Dodaj Server
+ Dodaj Korisnika
+ Audio
+ Mesto rođenja
+ Bitrate
+ Rođen
+ Prekini Snimanje
+ Prekini Snimanje Serije
+ Prekini
+ Poglavlja
+ Izaberi
+ Očisti keš slika
+ Kolekcija
+ Kolekcije
+ Reklama
+ Ocena Zajednice
+ Greška! Pritisni dugme da pošalješ logove serveru.
+ Potvrdi
+ Nastavi sa gledanjem
+ Ocena Kritičara
+ %.2f sekunde
+ %1$dh %2$dm %3$ds
+ %1$dh %2$dm
+ %1$dh %2$ds
+ %1$dm %2$ds
+ %1$dh
+ %1$dm
+ %1$ds
+ Podrazumevano
+ Obriši
+ Umro
+ Režirao %1$s
+ Režiser
+ Isključeno
+ Otkriveni Serveri
+
+ Preuzimanje…
+ Uključeno
+ Završava se u %1$s
+ Upiši Server IP ili URL
+ Upiši server adresu
+ Epizode
+ Greška pri učitavanju kolekcije %1$s
+ Eksterni
+ Omiljeni
+ Veličina
+ Forsiran
+ Žanrovi
+ Uđi u serije
+ Idi do
+ Sakrij kontrole za reprodukciju
+ Sakrij debug informacije
+ Sakrij
+ Kuća
+ Odmah
+ Intro
+ #ABCČĆDĐEFGHIJKLMNOPRSŠTUVZŽ
+ Biblioteka
+ Informacije o licenci
+ TV Uživo
+ Učitavanje…
+ Obeleži celu seriju kao odgledanu?
+ Obeleži celu seriju kao neodgledanu?
+ Obeleži kao neodgledano
+ Obeleži kao odgledano
+ Maks bitrate
+ Slično ovome
+ Još
+ Filmovi
+ Ime
+ Sledeće
+ Nema podataka
+ Nema rezultata
+ Server nije pronađen
+ Nema zakazanih snimaka
+ Nema dostupnih ažuriranja
+ Nijedan
+ Samo Forsirani Titlovi
+ Outro
+ Putanja
+ Ljudi
+ Broj Prikaza
+ Pusti odavde
+ Pusti
+ Prikaži debug info reprodukcije
+ Brzina Reprodukcije
+ Reprodukcija
+ Plejlista
+ Plejliste
+ Pregled
+ Korisnička Podešavanja
+ Korisnička podešavanja sa servera
+ Red
+ Rekapitulacija
+ Nedavno dodato u %1$s
+ Nedavno dodato
+ Nedavno Snimljeno
+ Nedavno Objavljeno
+ Preporučeno
+ Snimi Program
+ Snimi Seriju
+ Ukloni iz omiljenih
+ Restart
+ Nastavi
+ Sačuvaj
+
+ Pretraži
+ Pretraga…
+ Glasovna pretraga
+ Započinje
+ Pretraži govorom
+ Obrađuje se
+ Pritisni nazad za prekid
+ Greška u audio zapisu
+ Greška u prepoznavanju glasa
+ Potrebna je dozvola za mikrofon
+ Greška u mreži
+ Mreža izgubljena
+ Nije prepoznat govor
+ Prepoznavanje govora zauzeto
+ Greška u Serveru
+ Nije detektovan govor
+ Nepoznata greška
+ Greška u pokretanju prepoznavanja glasa
+ Greška u prepoznavanju glasa
+ Pokušaj ponovo
+ Odaberi Server
+ Odaberi Korisnika
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 3b71517a..002d5ece 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -686,4 +686,9 @@
Användarvisningar
%1$sfilmer
Kvalitetsprofil
+ HDR-format
+ HDR10
+ HDR10+
+ Tvinga aktivering
+ Tvinga avaktivering
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index f591c3fb..70804da5 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -13,7 +13,7 @@
Dizi kaydını iptal et
İptal
Bölümler
- Seç: %1$s
+ %1$s ögesini seçin
Resim önbelleğini temizle
Koleksiyon
Koleksiyonlar
@@ -718,4 +718,45 @@
Oynatma listesinden kaldır
Oturum süresi doldu, lütfen tekrar giriş yapın
Şu anda sunucuya erişim yetkiniz bulunmuyor
+ HDR10+ yerine Dolby Vision\'ı tercih et
+ Aksi takdirde bu videoları HDR10+ olarak oynatacak TV\'ler için, Dolby Vision videolardan HDR10+ meta verilerini kaldırır
+ Dönüştürme öncesi maksimum çözünürlük
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+
+ - %1$d Bölüm
+ - %1$d Bölüm
+
+ Yer Aldığı
+ En Yüksek Puanlı
+ HDR Formatları
+ HDR10
+ HDR10+
+ Dolby Vision Profile 5
+ Dolby Vision Profile 7
+ Dolby Vision Profile 8
+ Zorla Etkinleştir
+ Zorla Devre Dışı Bırak
+ Sunucu sürümü yakında desteklenmeyecek
+ Gerekli sunucu sürümü: %1$s
+ Seçen: %1$s
+ Kullanıcı
+ Diziler
+ Sunucudan varsayılanları yükle
+ Yalnızca bu cihaza kaydet
+ Varsayılan
+ Cihaza özel
+ Sunucu kullanıcı profili
+ Sunucu varsayılanı
+ Kullanıcı web istemcisi
+ Sunucu tarafından yönetiliyor
+ Hata
+ Dolby Vision Profile 7\'yi 8.1\'e Dönüştür
+ Profil 8\'i destekleyen ancak Profil 7\'yi desteklemeyen çözücülere sahip cihazlar için; çift katmanlı Profil 7\'yi çözücüye ulaşmadan önce tek katmanlı Profil 8.1\'e dönüştürür ve doğrudan oynatır
+ Sanatçı Kaynağı
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 7c10daab..c82ff0d8 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -697,4 +697,36 @@
從播放清單中移除
您目前無存取此伺服器的權限
登入憑證已過期,請重新登入
+
+ - %1$d 集
+
+ Dolby Vision 優先於 HDR10+
+ 移除 Dolby Vision 影片中的 HDR10+ 中介資料,以解決部分電視誤播為 HDR10+
+ 最高解析度,超過將轉碼
+ 8K
+ 4K
+ 1080
+ 720
+ 576
+ 480
+ 360
+ HDR10
+ HDR10+
+ 杜比視界 Profile 5
+ 杜比視界 Profile 7
+ 杜比視界 Profile 8
+ 強制啟用
+ 強制停用
+ 伺服器版本即將停止支援
+ 伺服器版本需為:%1$s
+ 使用者
+ 劇集
+ 從伺服器載入預設值
+ 僅儲存於此裝置
+ 預設
+ 高評分
+ HDR 格式
+ 由 %1$s 精選
+ 將杜比視界 Profile 7 轉換為 8.1
+ 在解碼器處理前,將雙層 Profile 7 重新改寫為單層 Profile 8.1,讓不支援 Profile 7 但支援 Profile 8 的裝置能夠直接播放影片
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 84ad72c7..3783bfdf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -154,6 +154,7 @@
Switch
%1$s left
Top Rated Unwatched
+ Top Rated
Trailers
DVR Schedule
Guide
@@ -174,7 +175,7 @@
Video Scale
Video
Videos
- Watch liveg
+ Watch live
- %1$d years old
- %1$d year old
@@ -414,6 +415,19 @@
Image disk cache size (MB)
Disable audio hardware offload
Disabling offloading audio decoding may fix playback freezes on some devices
+ HDR formats
+ HDR10
+ HDR10+
+ Dolby Vision Profile 5
+ Dolby Vision Profile 7
+ Dolby Vision Profile 8
+ Force enable
+ Force disable
+
+ - @string/automatic
+ - @string/force_enable
+ - @string/force_disable
+
View options
Columns
Spacing
@@ -490,6 +504,8 @@
Ignores device compatibility checks
Prefer Dolby Vision over HDR10+
Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+
+ Convert Dolby Vision Profile 7 to 8.1
+ Rewrites dual-layer Profile 7 into single-layer Profile 8.1 before the decoder and direct plays it, for devices whose decoder lists Profile 8 but not Profile 7
Discover
Request
@@ -792,6 +808,11 @@
Creator
Artist
Album artist
+ Artist source
+
+ - @string/artist
+ - @string/album_artist
+
Album
Popular songs
Play next
@@ -920,6 +941,8 @@
Books
Audio books
Server version not supported
+ Server version will not be supported soon
+ Required server version: %1$s
Appears on
Dim screen after delay
Dim percentage
@@ -953,4 +976,16 @@
- %1$d Episode
- %1$d Episodes
+ Chosen by %1$s
+ User
+ Series
+ Load default from server
+ Save only to this device
+ Default
+ Device specific
+ Server user profile
+ Server default
+ User web client
+ Managed by server
+ Error
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt
similarity index 61%
rename from app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt
rename to app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt
index 556a2fc0..1de5becd 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt
@@ -1,14 +1,12 @@
-package com.github.damontecres.wholphin.test
+package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.datastore.core.DataStore
-import com.github.damontecres.wholphin.MainActivityViewModel
import com.github.damontecres.wholphin.data.CurrentUser
-import com.github.damontecres.wholphin.data.JellyfinServerDao
+import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
-import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.services.AppUpgradeHandler
@@ -19,8 +17,10 @@ import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
+import com.github.damontecres.wholphin.test.currentUser
+import com.github.damontecres.wholphin.test.server
+import com.github.damontecres.wholphin.test.user
import com.github.damontecres.wholphin.ui.nav.Destination
-import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
@@ -36,11 +36,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.model.UUID
import org.junit.After
-import org.junit.Assert.assertEquals
+import org.junit.Assert
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -58,7 +58,6 @@ class TestMainActivityViewModel {
private val backdropService: BackdropService = mockk(relaxed = true)
private val appUpgradeHandler: AppUpgradeHandler = mockk()
private val intentService: IntentService = mockk()
- private val serverDao: JellyfinServerDao = mockk()
val viewModel =
MainActivityViewModel(
@@ -74,6 +73,7 @@ class TestMainActivityViewModel {
)
private val serverId = UUID.randomUUID()
+ private val server = server(serverId)
private val userId = UUID.randomUUID()
private val currentUser = currentUser(serverId, userId)
@@ -88,13 +88,6 @@ class TestMainActivityViewModel {
WholphinDispatchers.configure(testDispatcher)
every { appUpgradeHandler.needUpgrade() } returns false
every { appUpgradeHandler.copySubfont(any()) } returns Unit
- every { serverRepository.serverDao } returns serverDao
- coEvery { serverDao.getServer(serverId) } returns
- JellyfinServerUsers(
- currentUser.server,
- // User list is unused
- emptyList(),
- )
mockkStatic(Toast::class)
val mockToast = mockk()
every { Toast.makeText(any(), any(), any()) } returns mockToast
@@ -121,22 +114,26 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
+// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
+// coEvery { serverRepository.restoreLastSession() } returns
+// RestoredSession.Success(currentUser)
- viewModel.appStart(null)
- advanceUntilIdle()
+ viewModel.handleAppStart()
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is SetupDestination.AppContent,
+ )
destination as SetupDestination.AppContent
- assertEquals(currentUser, destination.current)
+ Assert.assertEquals(currentUser, destination.current)
}
+ coVerify(exactly = 0) { serverRepository.restoreLastSession() }
+ coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}
@Test
@@ -144,27 +141,27 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = false
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
+ coEvery { serverRepository.getMostRecentServer() } returns
+ RestoredSession.ServerOnly(server)
- viewModel.appStart(null)
- advanceUntilIdle()
+ viewModel.handleAppStart()
- verify(exactly = 1) { serverDao.getServer(serverId) }
- coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) }
+ coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
+ coVerify(exactly = 0) { serverRepository.restoreLastSession() }
+ coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
val args = mutableListOf()
verify { setupNavigationManager.navigateTo(capture(args)) }
- assertEquals(2, args.size)
+ Assert.assertEquals(1, args.size)
args[0].let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading)
- }
- args[1].let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is SetupDestination.UserList,
+ )
destination as SetupDestination.UserList
- assertEquals(currentUser.server, destination.server)
+ Assert.assertEquals(currentUser.server, destination.server)
}
}
@@ -173,27 +170,30 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
- every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
+ every { serverRepository.current } returns
+ MutableStateFlow(
+ protectedCurrentUser,
+ )
+ coEvery {
+ serverRepository.tryChangeUser(serverId, userId)
+ } returns null
+ coEvery { serverRepository.restoreLastSession() } returns
+ RestoredSession.ServerOnly(server)
- viewModel.appStart(null)
- advanceUntilIdle()
+ viewModel.handleAppStart()
- coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
- verify { serverDao.getServer(serverId) }
+ coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
val args = mutableListOf()
verify { setupNavigationManager.navigateTo(capture(args)) }
- assertEquals(2, args.size)
+ Assert.assertEquals(1, args.size)
args[0].let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading)
- }
- args[1].let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is SetupDestination.UserList,
+ )
destination as SetupDestination.UserList
- assertEquals(currentUser.server, destination.server)
+ Assert.assertEquals(currentUser.server, destination.server)
}
}
@@ -202,22 +202,26 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
+ coEvery { serverRepository.restoreLastSession() } returns
+ RestoredSession.ServerOnly(server)
+ coEvery {
+ serverRepository.tryChangeUser(serverId, userId)
+ } returns null
- viewModel.appStart(null)
- advanceUntilIdle()
+ viewModel.handleAppStart()
- coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
+ coVerify(exactly = 1) { serverRepository.restoreLastSession() }
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is SetupDestination.UserList,
+ )
destination as SetupDestination.UserList
- assertEquals(currentUser.server, destination.server)
+ Assert.assertEquals(currentUser.server, destination.server)
}
}
@@ -226,45 +230,49 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
- currentServerId = ""
- currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.restoreSession(null, null) } returns null
+ coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
- viewModel.appStart(null)
- advanceUntilIdle()
+ viewModel.handleAppStart()
- coVerify(exactly = 1) { serverRepository.restoreSession(null, null) }
+ coVerify(exactly = 1) { serverRepository.restoreLastSession() }
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is SetupDestination.ServerList,
+ )
}
}
@Test
- fun `Test intent result is noop`() =
+ fun `Test intent result is noop, hot reload`() =
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
+ coEvery { serverRepository.restoreLastSession() } returns
+ RestoredSession.Success(currentUser)
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
- viewModel.appStart(Intent())
- advanceUntilIdle()
+ val result = viewModel.handleIntent(Intent())
+ assertFalse(result)
- val slot = slot()
- verify { setupNavigationManager.navigateTo(capture(slot)) }
- slot.captured.let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
- destination as SetupDestination.AppContent
- assertEquals(currentUser, destination.current)
- }
+// val slot = slot()
+// verify { setupNavigationManager.navigateTo(capture(slot)) }
+// slot.captured.let { destination ->
+// Assert.assertTrue(
+// "destination is ${destination::class}",
+// destination is SetupDestination.AppContent,
+// )
+// destination as SetupDestination.AppContent
+// Assert.assertEquals(currentUser, destination.current)
+// }
+ coVerify(exactly = 0) { serverRepository.restoreLastSession() }
+ coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}
@Test
@@ -272,22 +280,22 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
- viewModel.appStart(Intent())
- advanceUntilIdle()
+ val result = viewModel.handleIntent(Intent())
+ assertTrue(result)
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is SetupDestination.UserList,
+ )
destination as SetupDestination.UserList
- assertEquals(currentUser.server, destination.server)
+ Assert.assertEquals(currentUser.server, destination.server)
}
}
@@ -296,20 +304,20 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
- viewModel.appStart(Intent())
- advanceUntilIdle()
+ val result = viewModel.handleIntent(Intent())
+ assertTrue(result)
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is SetupDestination.ServerList,
+ )
}
}
@@ -318,11 +326,9 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
+// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@@ -331,21 +337,27 @@ class TestMainActivityViewModel {
val backstack = mutableListOf()
every { navigationManager.backStack } returns backstack
- viewModel.appStart(Intent())
- advanceUntilIdle()
+ val result = viewModel.handleIntent(Intent())
+ assertTrue(result)
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify { navigationManager.reloadHome() }
slot.captured.let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is SetupDestination.AppContent,
+ )
destination as SetupDestination.AppContent
- assertEquals(currentUser, destination.current)
+ Assert.assertEquals(currentUser, destination.current)
}
// Note: navigation manager internally handles Home destination, so it won't be in this list
- assertEquals(1, backstack.size)
+ Assert.assertEquals(1, backstack.size)
backstack[0].let { destination ->
- assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is Destination.Favorites,
+ )
}
}
@@ -354,11 +366,9 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
+// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@@ -367,20 +377,26 @@ class TestMainActivityViewModel {
val backstack = mutableListOf()
every { navigationManager.backStack } returns backstack
- viewModel.appStart(Intent())
- advanceUntilIdle()
+ val result = viewModel.handleIntent(Intent())
+ assertTrue(result)
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify(exactly = 0) { navigationManager.reloadHome() }
slot.captured.let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is SetupDestination.AppContent,
+ )
destination as SetupDestination.AppContent
- assertEquals(currentUser, destination.current)
+ Assert.assertEquals(currentUser, destination.current)
}
- assertEquals(1, backstack.size)
+ Assert.assertEquals(1, backstack.size)
backstack[0].let { destination ->
- assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is Destination.Favorites,
+ )
}
}
@@ -389,11 +405,9 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
+// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@@ -402,14 +416,17 @@ class TestMainActivityViewModel {
val backstack = mutableListOf()
every { navigationManager.backStack } returns backstack
- viewModel.appStart(Intent())
- advanceUntilIdle()
+ val result = viewModel.handleIntent(Intent())
+ assertTrue(result)
val slot = slot()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify(exactly = 0) { navigationManager.reloadHome() }
slot.captured.let { destination ->
- assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
+ Assert.assertTrue(
+ "destination is ${destination::class}",
+ destination is SetupDestination.ServerList,
+ )
}
}
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt
new file mode 100644
index 00000000..11006322
--- /dev/null
+++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt
@@ -0,0 +1,48 @@
+package com.github.damontecres.wholphin.data.model
+
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import java.util.UUID
+
+class BaseItemSubtitleTest {
+ private fun album(
+ albumArtist: String? = null,
+ artists: List? = null,
+ productionYear: Int? = null,
+ ) = BaseItem(
+ BaseItemDto(
+ id = UUID.randomUUID(),
+ type = BaseItemKind.MUSIC_ALBUM,
+ name = "Test Album",
+ albumArtist = albumArtist,
+ artists = artists,
+ productionYear = productionYear,
+ ),
+ )
+
+ @Test
+ fun albumPrefersAlbumArtist() {
+ val item = album(albumArtist = "Album Artist", artists = listOf("A", "B"), productionYear = 2021)
+ assertEquals("Album Artist - 2021", item.subtitle)
+ }
+
+ @Test
+ fun albumFallsBackToArtists() {
+ val item = album(artists = listOf("A", "B"), productionYear = 2021)
+ assertEquals("A, B - 2021", item.subtitle)
+ }
+
+ @Test
+ fun albumWithoutYear() {
+ assertEquals("Album Artist", album(albumArtist = "Album Artist").subtitle)
+ }
+
+ @Test
+ fun albumWithoutArtist() {
+ assertEquals("2021", album(productionYear = 2021).subtitle)
+ assertNull(album(artists = emptyList()).subtitle)
+ }
+}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt
new file mode 100644
index 00000000..283ee8ca
--- /dev/null
+++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt
@@ -0,0 +1,149 @@
+package com.github.damontecres.wholphin.data.model
+
+import com.github.damontecres.wholphin.test.TestTracks
+import com.github.damontecres.wholphin.test.assertNotNull
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class RelativeMediaStreamTest {
+ @Test
+ fun `Test getRelativeIndex`() {
+ val source =
+ TestTracks
+ .Builder()
+ .addSubtitle(2)
+ .addAudio(2)
+ .addVideo()
+ .buildMediaSourceInfo()
+ val streams = source.mediaStreams!!
+ RelativeMediaStream.getRelativeIndex(0, streams).let { relativeIndex ->
+ assertNotNull(relativeIndex)
+ assertEquals(0, relativeIndex)
+ }
+ RelativeMediaStream.getRelativeIndex(1, streams).let { relativeIndex ->
+ assertNotNull(relativeIndex)
+ assertEquals(1, relativeIndex)
+ }
+ RelativeMediaStream.getRelativeIndex(2, streams).let { relativeIndex ->
+ assertNotNull(relativeIndex)
+ assertEquals(0, relativeIndex)
+ }
+ RelativeMediaStream.getRelativeIndex(3, streams).let { relativeIndex ->
+ assertNotNull(relativeIndex)
+ assertEquals(1, relativeIndex)
+ }
+ }
+
+ @Test
+ fun `Test getRelativeIndex for stream`() {
+ val source =
+ TestTracks
+ .Builder()
+ .addSubtitle(2)
+ .addAudio(2)
+ .addVideo()
+ .buildMediaSourceInfo()
+ val streams = source.mediaStreams!!
+ RelativeMediaStream.getRelativeIndex(streams[1], streams).let { relativeIndex ->
+ assertNotNull(relativeIndex)
+ assertEquals(1, relativeIndex)
+ }
+ }
+
+ @Test
+ fun `Test asRelative basic`() {
+ val source =
+ TestTracks
+ .Builder()
+ .addSubtitle(2)
+ .addAudio(2)
+ .addVideo()
+ .buildMediaSourceInfo()
+ val streams = source.mediaStreams!!
+ assertEquals(5, streams.size)
+
+ val relative = streams.asRelative()
+ // Subtitle
+ assertEquals(0, relative[0].relativeIndex)
+ assertEquals(0, relative[0].stream.index)
+ assertEquals(1, relative[1].relativeIndex)
+ assertEquals(1, relative[1].stream.index)
+
+ // Audio
+ assertEquals(0, relative[2].relativeIndex)
+ assertEquals(2, relative[2].stream.index)
+ assertEquals(1, relative[3].relativeIndex)
+ assertEquals(3, relative[3].stream.index)
+
+ // Video
+ assertEquals(0, relative[4].relativeIndex)
+ assertEquals(4, relative[4].stream.index)
+ }
+
+ @Test
+ fun `Test asRelative with external`() {
+ val source =
+ TestTracks
+ .Builder()
+ .addExternalSubtitle(2)
+ .addSubtitle(2)
+ .addAudio(2)
+ .buildMediaSourceInfo()
+ val streams = source.mediaStreams!!
+ assertEquals(6, streams.size)
+
+ val relative = streams.asRelative()
+ // External Subtitle
+ assertEquals(0, relative[0].relativeIndex)
+ assertEquals(0, relative[0].stream.index)
+ assertEquals(1, relative[1].relativeIndex)
+ assertEquals(1, relative[1].stream.index)
+
+ // Subtitle
+ assertEquals(0, relative[2].relativeIndex)
+ assertEquals(2, relative[2].stream.index)
+ assertEquals(1, relative[3].relativeIndex)
+ assertEquals(3, relative[3].stream.index)
+
+ // Audio
+ assertEquals(0, relative[4].relativeIndex)
+ assertEquals(4, relative[4].stream.index)
+ assertEquals(1, relative[5].relativeIndex)
+ assertEquals(5, relative[5].stream.index)
+ }
+
+ @Test
+ fun `Test asRelative with external mixed`() {
+ val source =
+ TestTracks
+ .Builder()
+ .addExternalSubtitle(1)
+ .addSubtitle(1)
+ .addAudio(1)
+ .addExternalSubtitle(1)
+ .addAudio(1)
+ .addSubtitle(1)
+ .buildMediaSourceInfo()
+ val streams = source.mediaStreams!!
+ assertEquals(6, streams.size)
+
+ val relative = streams.asRelative()
+ // External Subtitle
+ assertEquals(0, relative[0].relativeIndex)
+ assertEquals(0, relative[0].stream.index)
+ assertEquals(1, relative[3].relativeIndex)
+ assertEquals(3, relative[3].stream.index)
+
+ // Subtitle
+ assertEquals(0, relative[1].relativeIndex)
+ assertEquals(1, relative[1].stream.index)
+ assertEquals(1, relative[5].relativeIndex)
+ assertEquals(5, relative[5].stream.index)
+
+ // Audio
+ assertEquals(0, relative[2].relativeIndex)
+ assertEquals(2, relative[2].stream.index)
+ assertEquals(1, relative[4].relativeIndex)
+ assertEquals(4, relative[4].stream.index)
+ }
+}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt
new file mode 100644
index 00000000..d179c7e1
--- /dev/null
+++ b/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt
@@ -0,0 +1,207 @@
+package com.github.damontecres.wholphin.services
+
+import com.github.damontecres.wholphin.data.model.BaseItem
+import io.mockk.every
+import io.mockk.mockk
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.imageApi
+import org.jellyfin.sdk.api.operations.ImageApi
+import org.jellyfin.sdk.model.UUID
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.serializer.toUUID
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+
+class ImageUrlServiceTest {
+ private val mockApiClient = mockk()
+ private val imageUrlService = ImageUrlService(mockApiClient)
+
+ private val itemId = UUID.randomUUID()
+ private val seriesId = UUID.randomUUID()
+ private val seasonId = UUID.randomUUID()
+
+ @Before
+ fun setup() {
+ every { mockApiClient.baseUrl } returns "http://localhost"
+ every { mockApiClient.createUrl(any(), any(), any(), any()) } answers { callOriginal() }
+ every { mockApiClient.imageApi } returns ImageApi(mockApiClient)
+ }
+
+ private fun episode(
+ imageTags: Map = emptyMap(),
+ parentLogoItemId: UUID? = null,
+ parentThumbItemId: UUID? = null,
+ parentBackdropItemId: UUID? = null,
+ ) = BaseItemDto(
+ id = itemId,
+ type = BaseItemKind.EPISODE,
+ seriesId = seriesId,
+ seasonId = seasonId,
+ imageTags = imageTags,
+ seriesPrimaryImageTag = "seriesPrimary",
+ parentLogoItemId = parentLogoItemId,
+ parentLogoImageTag = parentLogoItemId?.let { "parentLogo" },
+ parentThumbItemId = parentThumbItemId,
+ parentThumbImageTag = parentThumbItemId?.let { "parentThumb" },
+ parentBackdropItemId = parentBackdropItemId,
+ parentBackdropImageTags = parentBackdropItemId?.let { listOf("parentBackdrop0", "parentBackdrop1") },
+ )
+
+ private fun movie(
+ imageTags: Map = emptyMap(),
+ backdropImageTags: List? = null,
+ ) = BaseItemDto(
+ id = itemId,
+ type = BaseItemKind.MOVIE,
+ imageTags = imageTags,
+ backdropImageTags = backdropImageTags,
+ )
+
+ private fun assertImage(
+ url: String?,
+ expectedId: UUID,
+ expectedType: ImageType,
+ expectedTag: String?,
+ ) {
+ Assert.assertNotNull(url)
+ val path = url!!.substringBefore('?')
+ Assert.assertEquals(expectedId, path.substringAfter("/Items/").substringBefore('/').toUUID())
+ Assert.assertEquals(expectedType.serialName, path.substringAfter("/Images/"))
+ val tag =
+ url
+ .substringAfter('?', "")
+ .split('&')
+ .firstOrNull { it.startsWith("tag=") }
+ ?.substringAfter("tag=")
+ Assert.assertEquals(expectedTag, tag)
+ }
+
+ @Test
+ fun `Own images use own tags`() {
+ val item =
+ BaseItem(
+ movie(
+ imageTags =
+ mapOf(
+ ImageType.PRIMARY to "primary",
+ ImageType.LOGO to "logo",
+ ImageType.THUMB to "thumb",
+ ImageType.BANNER to "banner",
+ ImageType.ART to "art",
+ ),
+ backdropImageTags = listOf("backdrop0", "backdrop1"),
+ ),
+ )
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, "primary")
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, "logo")
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.THUMB, "thumb")
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), itemId, ImageType.BANNER, "banner")
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.ART), itemId, ImageType.ART, "art")
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP), itemId, ImageType.BACKDROP, "backdrop0")
+ }
+
+ @Test
+ fun `Missing tags are omitted`() {
+ val item = BaseItem(movie())
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, null)
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, null)
+ Assert.assertNull(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP))
+ }
+
+ @Test
+ fun `Thumb falls back to own backdrop`() {
+ val item = BaseItem(movie(backdropImageTags = listOf("backdrop0")))
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.BACKDROP, "backdrop0")
+ }
+
+ @Test
+ fun `Episode primary uses series tag`() {
+ val item = BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true)
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary")
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), seriesId, ImageType.BANNER, null)
+ assertImage(
+ imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = false),
+ itemId,
+ ImageType.PRIMARY,
+ "primary",
+ )
+ }
+
+ @Test
+ fun `Season without primary uses series tag`() {
+ val item = BaseItem(episode().copy(id = seasonId, type = BaseItemKind.SEASON, seasonId = null))
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary")
+ }
+
+ @Test
+ fun `Episode logo uses parent logo tag only if from series`() {
+ assertImage(
+ imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seriesId)), ImageType.LOGO),
+ seriesId,
+ ImageType.LOGO,
+ "parentLogo",
+ )
+ assertImage(
+ imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seasonId)), ImageType.LOGO),
+ seriesId,
+ ImageType.LOGO,
+ null,
+ )
+ }
+
+ @Test
+ fun `Episode backdrop uses parent backdrop tag only if from series`() {
+ assertImage(
+ imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seriesId)), ImageType.BACKDROP),
+ seriesId,
+ ImageType.BACKDROP,
+ "parentBackdrop0",
+ )
+ assertImage(
+ imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seasonId)), ImageType.BACKDROP),
+ seriesId,
+ ImageType.BACKDROP,
+ null,
+ )
+ }
+
+ @Test
+ fun `Episode thumb uses parent tags`() {
+ assertImage(
+ imageUrlService.getItemImageUrl(
+ BaseItem(episode(parentThumbItemId = seriesId, parentBackdropItemId = seriesId), useSeriesForPrimary = true),
+ ImageType.THUMB,
+ ),
+ seriesId,
+ ImageType.THUMB,
+ "parentThumb",
+ )
+ assertImage(
+ imageUrlService.getItemImageUrl(
+ BaseItem(episode(parentBackdropItemId = seriesId), useSeriesForPrimary = true),
+ ImageType.THUMB,
+ ),
+ seriesId,
+ ImageType.BACKDROP,
+ "parentBackdrop0",
+ )
+ assertImage(
+ imageUrlService.getItemImageUrl(
+ BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true),
+ ImageType.THUMB,
+ ),
+ itemId,
+ ImageType.PRIMARY,
+ "primary",
+ )
+ }
+
+ @Test
+ fun `Season without thumb uses parent thumb tag`() {
+ val item = BaseItem(episode(parentThumbItemId = seriesId).copy(id = seasonId, type = BaseItemKind.SEASON))
+ assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), seriesId, ImageType.THUMB, "parentThumb")
+ }
+}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt
index ee32c7dd..2bcc6ab7 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt
@@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent
import com.github.damontecres.wholphin.data.CurrentUser
-import com.github.damontecres.wholphin.data.JellyfinServerDao
+import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
@@ -20,7 +20,6 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
-import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
@@ -42,7 +41,6 @@ import org.robolectric.annotation.Config
class IntentServiceTest {
private val api: ApiClient = mockk()
private val serverRepository: ServerRepository = mockk()
- private val serverDao: JellyfinServerDao = mockk()
private val userPreferencesService: UserPreferencesService = mockk()
private val userLibraryApi: UserLibraryApi = mockk()
@@ -64,7 +62,6 @@ class IntentServiceTest {
intentService = IntentService(api, serverRepository, userPreferencesService)
every { api.userLibraryApi } returns userLibraryApi
coEvery { userLibraryApi.getItem(itemId) } returns successResponse(movie(itemId))
- every { serverRepository.serverDao } returns serverDao
}
private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) {
@@ -82,11 +79,11 @@ class IntentServiceTest {
private fun setupAutoSignInUnprotected() {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
+ coEvery { serverRepository.restoreLastSession() } returns
+ RestoredSession.Success(currentUser)
+ coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
}
@Test
@@ -98,7 +95,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertNull(result)
- coVerify { serverRepository.restoreSession(serverId, userId) }
+ coVerify { serverRepository.restoreLastSession() }
}
@Test
@@ -106,17 +103,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = false
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
+ coEvery { serverRepository.restoreLastSession() } returns
+ RestoredSession.Success(currentUser)
+ coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
+ coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@@ -124,17 +121,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
+ coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
+ coEvery { serverRepository.restoreLastSession() } returns
+ RestoredSession.ServerOnly(protectedCurrentUser.server)
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
+ coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@@ -142,17 +139,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
- currentServerId = serverId.toServerString()
- currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
+ coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
+ coEvery { serverRepository.restoreLastSession() } returns
+ RestoredSession.ServerOnly(protectedCurrentUser.server)
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
+ coVerify(exactly = 1) { serverRepository.restoreLastSession() }
}
@Test
@@ -160,17 +157,16 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
- currentServerId = ""
- currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow(null)
+ coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
// coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
+ coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@@ -178,12 +174,11 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
- currentServerId = ""
- currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user
- coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
+ coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
+ coEvery { serverRepository.restoreLastSession() } returns
+ RestoredSession.Success(currentUser)
val intent =
Intent().apply {
@@ -193,7 +188,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertNull(result)
- coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
+ coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@@ -201,12 +196,11 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
- currentServerId = ""
- currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user
- coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
+ coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
+// coEvery { serverRepository.restoreLastSession() } returns
+// RestoredSession.ServerOnly(protectedCurrentUser.server)
val intent =
Intent().apply {
@@ -216,7 +210,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
+ coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@@ -224,12 +218,9 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
- currentServerId = ""
- currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow(null)
- coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null
- coEvery { serverRepository.restoreSession(serverId, userId) } returns null
+ coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
val intent =
Intent().apply {
@@ -239,7 +230,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
- coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
+ coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@@ -296,12 +287,11 @@ class IntentServiceTest {
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery {
- serverRepository.restoreSession(
+ serverRepository.tryChangeUser(
newServerId,
newUserId,
)
} returns newCurrentUser
- every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
val intent =
Intent().apply {
@@ -316,9 +306,8 @@ class IntentServiceTest {
assertEquals(0, destinations.size)
assertTrue(result.addHomeToBackStack)
- coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
- verify { serverDao.getUser(newServerId, newUserId) }
- coVerify { serverRepository.restoreSession(newServerId, newUserId) }
+ coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
+ coVerify { serverRepository.tryChangeUser(newServerId, newUserId) }
coVerify(exactly = 0) { userLibraryApi.getItem(itemId) }
}
@@ -333,12 +322,11 @@ class IntentServiceTest {
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery {
- serverRepository.restoreSession(
+ serverRepository.tryChangeUser(
newServerId,
newUserId,
)
} returns newCurrentUser
- every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
val intent =
Intent().apply {
@@ -358,9 +346,8 @@ class IntentServiceTest {
assertEquals(itemId, first.itemId)
- coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
- verify { serverDao.getUser(newServerId, newUserId) }
- coVerify { serverRepository.restoreSession(newServerId, newUserId) }
+ coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
+ coVerify { serverRepository.tryChangeUser(newServerId, newUserId) }
coVerify { userLibraryApi.getItem(itemId) }
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt
index 499208d6..2c2fdffe 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt
@@ -1,11 +1,13 @@
package com.github.damontecres.wholphin.services
import android.content.Context
-import android.content.SharedPreferences
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
+import com.github.damontecres.wholphin.data.MostRecentServer
+import com.github.damontecres.wholphin.data.MostRecentServerProvider
+import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
@@ -14,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import com.github.damontecres.wholphin.test.nonBlankString
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
@@ -21,6 +24,7 @@ import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import io.mockk.Runs
import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -29,7 +33,6 @@ import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
@@ -48,6 +51,8 @@ import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.UserDto
import org.junit.After
import org.junit.Assert
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -80,14 +85,13 @@ class ServerRepositoryTest {
private val mockUserApi = mockk()
private val mockSystemApi = mockk()
- private val mockSharedPreferences = mockk(relaxed = true)
+ private val mockMostRecentServerProvider = mockk()
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
WholphinDispatchers.configure(testDispatcher)
- every { mockContext.getSharedPreferences(any(), any()) } returns mockSharedPreferences
every { mockApiClient.userApi } returns mockUserApi
every { mockApiClient.systemApi } returns mockSystemApi
coEvery { mockUserApi.getCurrentUser() } returns successResponse(userDto)
@@ -102,9 +106,15 @@ class ServerRepositoryTest {
emptyMap(),
)
coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
+ coEvery { mockJellyfinServerDao.getServer(serverId) } returns
+ JellyfinServerUsers(server, listOf(user))
+ coEvery { mockJellyfinServerDao.addOrUpdateServer(server) } just Runs
every { mockApiClient.clientInfo } returns ClientInfo("Wholphin test", "0.0.1")
every { mockApiClient.deviceInfo } returns DeviceInfo("Wholphin test ID", "Wholphin test device")
every { mockApiClient.update(any(), any(), any(), any()) } just Runs
+ coEvery { mockMostRecentServerProvider.save(any()) } just Runs
+ coEvery { mockMostRecentServerProvider.clear() } just Runs
+ coEvery { mockMostRecentServerProvider.clearUser() } just Runs
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -119,8 +129,8 @@ class ServerRepositoryTest {
mockJellyfin,
mockJellyfinServerDao,
mockApiClient,
- dataStore,
testDispatcher,
+ mockMostRecentServerProvider,
)
private val serverId = UUID.randomUUID()
@@ -194,24 +204,24 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
- val appPreferences = dataStore.data.first()
- Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
- Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
-
- verify(exactly = 1) { mockSharedPreferences.edit() }
+ coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) }
}
@Test
fun `Test restoreUser via changeUser`() =
runTest {
+ val user = user.copy(pin = null)
every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs
every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user))
val serverRepository = create()
Assert.assertNull(serverRepository.currentUser)
- serverRepository.restoreSession(server.id, user.id)
+ val result = serverRepository.tryChangeUser(server.id, user.id)
+ assertTrue(result != null)
+ assertEquals(serverId, result?.server?.id)
+ assertEquals(user.id, result?.user?.id)
verify(exactly = 1) {
mockApiClient.update(
@@ -226,34 +236,6 @@ class ServerRepositoryTest {
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
}
- @Test
- fun `Test restoreUser via shortcut`() =
- runTest {
- every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user))
-
- val serverRepository = create()
- setUpCurrentUser(serverRepository)
-
- serverRepository.restoreSession(server.id, user.id)
-
- verify(exactly = 1) {
- mockApiClient.update(
- baseUrl = server.url,
- accessToken = user.accessToken,
- clientInfo = any(),
- deviceInfo = any(),
- )
- }
- verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateServer(any()) }
- verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateUser(any()) }
-
- Assert.assertEquals(server, serverRepository.currentServer)
- Assert.assertEquals(user, serverRepository.currentUser)
-
- // TODO fix this, need to check userDto too
-// Assert.assertEquals(userDto, serverRepository.currentUserDto)
- }
-
@Test
fun `Test remove user`() =
runTest {
@@ -308,6 +290,7 @@ class ServerRepositoryTest {
)
}
verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) }
+ coVerify { mockMostRecentServerProvider.clearUser() }
}
@Test
@@ -351,6 +334,7 @@ class ServerRepositoryTest {
)
}
verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) }
+ coVerify { mockMostRecentServerProvider.clear() }
}
@Test
@@ -387,11 +371,7 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
- val appPreferences = dataStore.data.first()
- Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
- Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
-
- verify(exactly = 1) { mockSharedPreferences.edit() }
+ coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
}
@Test
@@ -430,11 +410,7 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
- val appPreferences = dataStore.data.first()
- Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
- Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
-
- verify(exactly = 1) { mockSharedPreferences.edit() }
+ coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
}
@Test
@@ -484,4 +460,99 @@ class ServerRepositoryTest {
}
}
}
+
+ @Test
+ fun `Test restoreLastSession, success`() =
+ runTest {
+ val user = user.copy(pin = null)
+ coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
+ coEvery { mockJellyfinServerDao.getServer(serverId) } returns
+ JellyfinServerUsers(server, listOf(user))
+ every { mockMostRecentServerProvider.get() } returns
+ MostRecentServer.from(CurrentUser(server, user))
+
+ val result = create().restoreLastSession()
+ assertTrue(result is RestoredSession.Success)
+ val current = (result as RestoredSession.Success).currentUser
+ assertEquals(server, current.server)
+ assertEquals(user, current.user)
+
+ verify(exactly = 1) {
+ mockApiClient.update(
+ baseUrl = server.url,
+ accessToken = user.accessToken,
+ clientInfo = any(),
+ deviceInfo = any(),
+ )
+ }
+ coVerify { mockMostRecentServerProvider.save(any()) }
+ }
+
+ @Test
+ fun `Test restoreLastSession, user protected`() =
+ runTest {
+ every { mockMostRecentServerProvider.get() } returns
+ MostRecentServer.ServerAndUser(serverId, server.url, userId, "token")
+
+ val result = create().restoreLastSession()
+ assertTrue(result is RestoredSession.ServerOnly)
+ val current = (result as RestoredSession.ServerOnly)
+ assertEquals(server, current.server)
+ }
+
+ @Test
+ fun `Test restoreLastSession, server only`() =
+ runTest {
+ every { mockMostRecentServerProvider.get() } returns
+ MostRecentServer.Server(server.id, server.url)
+
+ val result = create().restoreLastSession()
+ assertTrue(result is RestoredSession.ServerOnly)
+ val current = (result as RestoredSession.ServerOnly)
+ assertEquals(server, current.server)
+ }
+
+ @Test
+ fun `Test restoreLastSession, nothing to restore`() =
+ runTest {
+ every { mockMostRecentServerProvider.get() } returns MostRecentServer.None
+
+ val result = create().restoreLastSession()
+ assertTrue(result is RestoredSession.None)
+ }
+}
+
+class TestMostRecentServerProvider : MostRecentServerProvider {
+ var mostRecentServer: MostRecentServer = MostRecentServer.None
+
+ override fun get(): MostRecentServer = mostRecentServer
+
+ override suspend fun save(current: CurrentUser) {
+ mostRecentServer =
+ if (current.user.accessToken.isNotNullOrBlank()) {
+ MostRecentServer.ServerAndUser(
+ current.server.id,
+ current.server.url,
+ current.user.id,
+ current.user.accessToken,
+ )
+ } else {
+ MostRecentServer.Server(
+ current.server.id,
+ current.server.url,
+ )
+ }
+ }
+
+ override suspend fun clear() {
+ mostRecentServer = MostRecentServer.None
+ }
+
+ override suspend fun clearUser() {
+ when (val s = mostRecentServer) {
+ MostRecentServer.None -> Unit
+ is MostRecentServer.Server -> Unit
+ is MostRecentServer.ServerAndUser -> MostRecentServer.Server(s.serverId, s.serverUrl)
+ }
+ }
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt
index b300e09a..d8304897 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt
@@ -1,10 +1,12 @@
package com.github.damontecres.wholphin.services
-import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
+import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.ActivationFlag
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
-import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -523,6 +525,54 @@ class TestStreamChoiceServiceOnlyForced(
),
streamAudioLang = "eng",
),
+ TestInput(
+ 0,
+ SubtitlePlaybackMode.NONE,
+ userSubtitleLang = null,
+ subtitles =
+ listOf(
+ subtitle(0, "eng", forced = true),
+ subtitle(1, "spa", forced = false),
+ ),
+ streamAudioLang = "eng",
+ plc = plc(subtitlesOnlyForced = true),
+ ),
+ TestInput(
+ 0,
+ SubtitlePlaybackMode.DEFAULT,
+ userSubtitleLang = null,
+ subtitles =
+ listOf(
+ subtitle(0, "eng", forced = true),
+ subtitle(1, "spa", forced = true),
+ ),
+ streamAudioLang = "eng",
+ plc = plc(subtitlesOnlyForced = true),
+ ),
+ TestInput(
+ 0,
+ SubtitlePlaybackMode.DEFAULT,
+ userSubtitleLang = "eng",
+ subtitles =
+ listOf(
+ subtitle(0, "eng", forced = true),
+ subtitle(1, "spa", forced = false),
+ ),
+ streamAudioLang = "eng",
+ plc = plc(subtitlesOnlyForced = true),
+ ),
+ TestInput(
+ null,
+ SubtitlePlaybackMode.NONE,
+ userSubtitleLang = null,
+ subtitles =
+ listOf(
+ subtitle(0, "eng", forced = false),
+ subtitle(1, "spa", forced = false),
+ ),
+ streamAudioLang = "eng",
+ plc = plc(subtitlesOnlyForced = true),
+ ),
)
}
}
@@ -885,7 +935,7 @@ data class TestInput(
val streamAudioLang: String? = "eng",
val subtitles: List,
val itemPlayback: ItemPlayback? = null,
- val plc: PlaybackLanguageChoice? = null,
+ val plc: List = emptyList(),
val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE,
val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE,
val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE,
@@ -895,7 +945,7 @@ data class TestInput(
private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)"
-private fun serverRepo(
+fun mockServerRepo(
audioLang: String?,
subtitleMode: SubtitlePlaybackMode?,
subtitleLang: String?,
@@ -918,15 +968,15 @@ private fun serverRepo(
private fun runTest(input: TestInput) {
val service =
StreamChoiceService(
- serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
- mockk(),
+ mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
+ mockk(),
)
val result =
service.chooseSubtitleStream(
audioStreamLang = input.streamAudioLang,
candidates = input.subtitles,
itemPlayback = input.itemPlayback,
- playbackLanguageChoice = input.plc,
+ stc = input.plc,
prefs =
UserPreferences(
AppPreferences.getDefaultInstance(),
@@ -937,7 +987,7 @@ private fun runTest(input: TestInput) {
),
),
)
- Assert.assertEquals(input.expectedIndex, result?.index)
+ Assert.assertEquals(input.expectedIndex, result.stream?.index)
}
fun subtitle(
@@ -945,6 +995,8 @@ fun subtitle(
lang: String?,
default: Boolean = false,
forced: Boolean = false,
+ sdh: Boolean = false,
+ external: Boolean = false,
title: String? = null,
): MediaStream =
MediaStream(
@@ -952,6 +1004,26 @@ fun subtitle(
language = lang,
isDefault = default,
isForced = forced,
+ isHearingImpaired = sdh,
+ isInterlaced = false,
+ index = index,
+ isExternal = external,
+ isTextSubtitleStream = true,
+ supportsExternalStream = true,
+ title = title,
+ )
+
+fun audio(
+ index: Int,
+ lang: String?,
+ default: Boolean = false,
+ title: String? = null,
+): MediaStream =
+ MediaStream(
+ type = MediaStreamType.AUDIO,
+ language = lang,
+ isDefault = default,
+ isForced = false,
isHearingImpaired = false,
isInterlaced = false,
index = index,
@@ -978,11 +1050,63 @@ private fun plc(
audioLang: String? = null,
subtitleLang: String? = null,
subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null,
-): PlaybackLanguageChoice =
- PlaybackLanguageChoice(
- userId = 1,
- seriesId = UUID.randomUUID(),
- audioLanguage = audioLang,
- subtitleLanguage = subtitleLang,
- subtitlesDisabled = subtitlesDisabled,
- )
+ subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null,
+): List =
+ buildList {
+ audioLang
+ ?.let {
+ SeriesTrackChoice(
+ userId = 1,
+ language = audioLang,
+ parentId = UUID.randomUUID(),
+ type = SeriesTrackChoiceType.AUDIO,
+ activation = ActivationFlag.ACTIVATED,
+ trackFlags = 0,
+ codec = null,
+ relativeTrackIndex = null,
+ title = null,
+ audioChannels = null,
+ )
+ }?.let(::add)
+
+ if (subtitleLang != null) {
+ SeriesTrackChoice(
+ userId = 1,
+ language = subtitleLang,
+ parentId = UUID.randomUUID(),
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.ACTIVATED,
+ trackFlags = 0,
+ codec = null,
+ relativeTrackIndex = null,
+ title = null,
+ audioChannels = null,
+ ).let(::add)
+ } else if (subtitlesDisabled == true) {
+ SeriesTrackChoice(
+ userId = 1,
+ language = null,
+ parentId = UUID.randomUUID(),
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.DISABLED,
+ trackFlags = 0,
+ codec = null,
+ relativeTrackIndex = null,
+ title = null,
+ audioChannels = null,
+ ).let(::add)
+ } else if (subtitlesOnlyForced == true) {
+ SeriesTrackChoice(
+ userId = 1,
+ language = null,
+ parentId = UUID.randomUUID(),
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.ONLY_FORCED,
+ trackFlags = 0,
+ codec = null,
+ relativeTrackIndex = null,
+ title = null,
+ audioChannels = null,
+ ).let(::add)
+ }
+ }
diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt
index e066e837..0e5e0eba 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt
@@ -5,8 +5,10 @@ import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.data.CurrentUser
+import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.test.currentUser
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import io.mockk.coEvery
import io.mockk.coVerify
@@ -113,16 +115,16 @@ class SuggestionsWorkerTest {
val mockUser = mockk()
var restored = false
every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) }
- coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
+ coEvery { mockServerRepository.restoreLastSession() } coAnswers {
restored = true
- mockUser
+ RestoredSession.Success(currentUser())
}
every { mockPreferences.data } returns flowOf(mockPrefs())
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult()
val result = createWorker().doWork()
- coVerify { mockServerRepository.restoreSession(testServerId, testUserId) }
+ coVerify { mockServerRepository.restoreLastSession() }
assertEquals(ListenableWorker.Result.success(), result)
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt
new file mode 100644
index 00000000..e38479f0
--- /dev/null
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt
@@ -0,0 +1,85 @@
+package com.github.damontecres.wholphin.test
+
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.HdrOverrideMode
+import com.github.damontecres.wholphin.preferences.PlaybackOverrides
+import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
+import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
+import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType
+import org.junit.Assert
+import org.junit.Test
+
+class TestHdrFormat {
+ private fun appPreferences(block: PlaybackOverrides.Builder.() -> Unit): AppPreferences =
+ AppPreferences.getDefaultInstance().updatePlaybackOverrides(block)
+
+ @Test
+ fun `Test all auto returns empty sets`() {
+ val prefs = appPreferences { }
+ Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true))
+ Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true))
+ }
+
+ @Test
+ fun `Test HDR10 override`() {
+ val prefs = appPreferences { hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE }
+ Assert.assertEquals(
+ setOf(WholphinVideoRangeType.HDR10.serialName),
+ prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
+ )
+ Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true))
+ }
+
+ @Test
+ fun `Test Dolby Vision Profile 5 override`() {
+ val prefs = appPreferences { doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE }
+ Assert.assertEquals(
+ setOf(WholphinVideoRangeType.DOVI.serialName),
+ prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
+ )
+ }
+
+ @Test
+ fun `Test Dolby Vision Profile 7 override requires Jellyfin 10_11`() {
+ val prefs = appPreferences { doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE }
+ Assert.assertEquals(
+ emptySet(),
+ prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = false),
+ )
+ Assert.assertEquals(
+ setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName),
+ prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
+ )
+ }
+
+ @Test
+ fun `Test Dolby Vision Profile 8 override`() {
+ val prefs = appPreferences { doviProfile8Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE }
+ Assert.assertEquals(
+ setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName),
+ prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = false),
+ )
+ Assert.assertEquals(
+ setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName, WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName),
+ prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
+ )
+ }
+
+ @Test
+ fun `Test multiple formats set to the same mode are unioned`() {
+ val prefs =
+ appPreferences {
+ hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE
+ hdr10PlusOverride = HdrOverrideMode.HDR_OVERRIDE_ENABLE
+ doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE
+ }
+ Assert.assertEquals(
+ setOf(WholphinVideoRangeType.HDR10.serialName, WholphinVideoRangeType.HDR10_PLUS.serialName),
+ prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
+ )
+ Assert.assertEquals(
+ setOf(WholphinVideoRangeType.DOVI.serialName),
+ prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
+ )
+ }
+}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt
index e8c2fed1..a6fe6e2a 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt
@@ -1,7 +1,9 @@
package com.github.damontecres.wholphin.test
+import com.github.damontecres.wholphin.data.model.HomeGetItemsFilter
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
+import com.github.damontecres.wholphin.data.model.KeyValueEntry
import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.ui.AspectRatio
@@ -13,7 +15,6 @@ import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
-import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.junit.Assert
import org.junit.Test
import kotlin.reflect.KClass
@@ -61,8 +62,8 @@ class TestHomeRowSamples {
),
HomeRowConfig.GetItems(
name = "Episodes by date created",
- getItems =
- GetItemsRequest(
+ filter =
+ HomeGetItemsFilter(
parentId = UUID.randomUUID(),
recursive = true,
isFavorite = true,
@@ -77,6 +78,12 @@ class TestHomeRowSamples {
HomeRowConfig.TvPrograms(),
HomeRowConfig.TvChannels(),
HomeRowConfig.Suggestions(parentId = UUID.randomUUID()),
+ HomeRowConfig.CustomEndpoint(
+ endpoint = "/path/to/items",
+ title = "Items",
+ query = listOf(KeyValueEntry("key", "value")),
+ headers = listOf(KeyValueEntry("X-Header", "1234")),
+ ),
)
}
@@ -100,6 +107,7 @@ class TestHomeRowSamples {
is HomeRowConfig.Suggestions -> foundTypes.add(it::class)
is HomeRowConfig.TvChannels -> foundTypes.add(it::class)
is HomeRowConfig.Studios -> foundTypes.add(it::class)
+ is HomeRowConfig.CustomEndpoint -> foundTypes.add(it::class)
}
}
Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size)
@@ -126,12 +134,10 @@ class TestHomeRowSamples {
context = mockk(),
api = mockk(),
serverRepository = mockk(),
- userPreferencesService = mockk(),
navDrawerService = mockk(),
- latestNextUpService = mockk(),
- imageUrlService = mockk(),
- suggestionService = mockk(),
displayPreferencesService = mockk(),
+ serverPluginApi = mockk(),
+ serverDao = mockk(),
)
val str = """{
@@ -149,7 +155,7 @@ class TestHomeRowSamples {
]
}"""
- val jsonElement = service.jsonParser.parseToJsonElement(str)
+ val jsonElement = HomeSettingsService.jsonParser.parseToJsonElement(str)
val settings = service.decode(jsonElement)
Assert.assertEquals(1, settings.rows.size)
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt
new file mode 100644
index 00000000..e58d8aec
--- /dev/null
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt
@@ -0,0 +1,534 @@
+package com.github.damontecres.wholphin.test
+
+import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
+import com.github.damontecres.wholphin.data.model.ActivationFlag
+import com.github.damontecres.wholphin.data.model.ItemPlayback
+import com.github.damontecres.wholphin.data.model.JellyfinUser
+import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
+import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
+import com.github.damontecres.wholphin.data.model.TrackFlag
+import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.StreamChoiceReason
+import com.github.damontecres.wholphin.services.StreamChoiceService
+import com.github.damontecres.wholphin.services.audio
+import com.github.damontecres.wholphin.services.mockServerRepo
+import com.github.damontecres.wholphin.services.subtitle
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.jellyfin.sdk.model.UUID
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.MediaStreamType
+import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class TestSeriesTrackChoice {
+ private val mockServerRepository =
+ mockServerRepo(
+ audioLang = "eng",
+ subtitleLang = "eng",
+ subtitleMode = SubtitlePlaybackMode.DEFAULT,
+ )
+ private val mockSeriesTrackChoiceDao = mockk(relaxed = false)
+
+ private val streamChoiceService =
+ StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
+
+ private val seriesId = UUID.randomUUID()
+ private val season1Id = UUID.randomUUID()
+ private val season2Id = UUID.randomUUID()
+ private val season3Id = UUID.randomUUID()
+
+ private val user =
+ JellyfinUser(
+ 1,
+ UUID.randomUUID(),
+ null,
+ UUID.randomUUID(),
+ null,
+ )
+
+ @Before
+ fun before() {
+ every { mockServerRepository.currentUser } returns user
+ }
+
+ @Test
+ fun `Test StreamChoiceService call correct dao function`() =
+ runTest {
+ val mockSeriesTrackChoiceDao = mockk(relaxed = true)
+ val streamChoiceService =
+ StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
+
+ val seriesId = UUID.randomUUID()
+ val seasonId = UUID.randomUUID()
+
+ // Has both series & season
+ BaseItemDto(
+ id = UUID.randomUUID(),
+ type = BaseItemKind.EPISODE,
+ seriesId = seriesId,
+ parentId = seasonId,
+ ).let { item ->
+ streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
+ coVerify(exactly = 1) {
+ mockSeriesTrackChoiceDao.get(any(), seriesId = seriesId, seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE)
+ }
+ }
+
+ // Has only series
+ BaseItemDto(
+ id = UUID.randomUUID(),
+ type = BaseItemKind.EPISODE,
+ seriesId = seriesId,
+ parentId = null,
+ ).let { item ->
+ streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
+ coVerify(exactly = 1) {
+ mockSeriesTrackChoiceDao.getBySeriesId(any(), seriesId = seriesId, type = SeriesTrackChoiceType.SUBTITLE)
+ }
+ }
+
+ // Has only season
+ BaseItemDto(
+ id = UUID.randomUUID(),
+ type = BaseItemKind.EPISODE,
+ seriesId = null,
+ parentId = seasonId,
+ ).let { item ->
+ streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
+ coVerify(exactly = 1) {
+ mockSeriesTrackChoiceDao.getBySeasonId(any(), seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE)
+ }
+ }
+ }
+
+ @Test
+ fun `Test series disabled`() =
+ runTest {
+ val result =
+ streamChoiceService.chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback = null,
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ subtitle(0, "eng", default = true),
+ subtitle(1, "spa"),
+ ),
+ stc =
+ listOf(
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = seriesId,
+ parentType = TrackChoiceParentType.SERIES,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.DISABLED,
+ ),
+ ),
+ )
+ assertNull(result.stream)
+ }
+
+ @Test
+ fun `Test series only forced`() =
+ runTest {
+ val mockServerRepository =
+ mockServerRepo(
+ audioLang = "eng",
+ subtitleLang = "eng",
+ subtitleMode = SubtitlePlaybackMode.NONE,
+ )
+ val streamChoiceService =
+ StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
+
+ val result =
+ streamChoiceService.chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback = null,
+ prefs =
+ UserPreferences(
+ AppPreferences.getDefaultInstance(),
+ JellyfinUserPreferences(),
+ ),
+ candidates =
+ listOf(
+ subtitle(0, "eng", forced = true),
+ subtitle(1, "eng", forced = false),
+ ),
+ stc =
+ listOf(
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = seriesId,
+ parentType = TrackChoiceParentType.SERIES,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.ONLY_FORCED,
+ ),
+ ),
+ )
+ assertNotNull(result.stream)
+ assertEquals(0, result.stream!!.index)
+ assertTrue(result.reason is StreamChoiceReason.Series)
+ }
+
+ @Test
+ fun `Test series disabled with season`() =
+ runTest {
+ val result =
+ streamChoiceService.chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback = null,
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ subtitle(0, "eng", default = true),
+ subtitle(1, "spa"),
+ ),
+ stc =
+ listOf(
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = seriesId,
+ parentType = TrackChoiceParentType.SERIES,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.DISABLED,
+ ),
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = season1Id,
+ parentType = TrackChoiceParentType.SEASON,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.DISABLED,
+ ),
+ ),
+ )
+ assertNull(result.stream)
+ assertTrue(result.reason is StreamChoiceReason.Series)
+ }
+
+ @Test
+ fun `Test item playback overrides STC for subtitles`() =
+ runTest {
+ val result =
+ streamChoiceService.chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback =
+ ItemPlayback(
+ userId = user.rowId,
+ itemId = UUID.randomUUID(),
+ subtitleIndex = 1,
+ ),
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ subtitle(0, "eng", default = true),
+ subtitle(1, "spa"),
+ ),
+ stc =
+ listOf(
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = seriesId,
+ parentType = TrackChoiceParentType.SERIES,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.DISABLED,
+ ),
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = season1Id,
+ parentType = TrackChoiceParentType.SEASON,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.DISABLED,
+ ),
+ ),
+ )
+ assertNotNull(result.stream)
+ assertEquals(1, result.stream!!.index)
+ assertTrue(result.reason is StreamChoiceReason.Item)
+ }
+
+ @Test
+ fun `Test item playback overrides STC for audio`() =
+ runTest {
+ val result =
+ streamChoiceService.chooseAudioStream(
+ itemPlayback =
+ ItemPlayback(
+ userId = user.rowId,
+ itemId = UUID.randomUUID(),
+ audioIndex = 1,
+ ),
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ audio(0, "eng", default = true),
+ audio(1, "spa"),
+ ),
+ stc =
+ listOf(
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = seriesId,
+ parentType = TrackChoiceParentType.SERIES,
+ type = SeriesTrackChoiceType.AUDIO,
+ language = "eng",
+ ),
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = season1Id,
+ parentType = TrackChoiceParentType.SEASON,
+ type = SeriesTrackChoiceType.AUDIO,
+ language = "eng",
+ ),
+ ),
+ )
+ assertNotNull(result.stream)
+ assertEquals(1, result.stream!!.index)
+ assertTrue(result.reason is StreamChoiceReason.Item)
+ }
+
+ @Test
+ fun `Test season STC doesn't apply but series STC does - subtitle`() =
+ runTest {
+ val result =
+ streamChoiceService.chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback =
+ ItemPlayback(
+ userId = user.rowId,
+ itemId = UUID.randomUUID(),
+ audioIndex = 1,
+ ),
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ subtitle(0, "eng", default = true),
+ subtitle(1, "spa"),
+ ),
+ stc =
+ listOf(
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = season1Id,
+ parentType = TrackChoiceParentType.SEASON,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.ONLY_FORCED,
+ ),
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = seriesId,
+ parentType = TrackChoiceParentType.SERIES,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.ACTIVATED,
+ language = "spa",
+ ),
+ ),
+ )
+ assertNotNull(result.stream)
+ assertEquals(1, result.stream!!.index)
+ assertTrue(result.reason is StreamChoiceReason.Series)
+ }
+
+ @Test
+ fun `Test season STC doesn't apply but series STC does - audio`() =
+ runTest {
+ val result =
+ streamChoiceService.chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback = null,
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ audio(0, "eng", default = true),
+ audio(1, "spa"),
+ ),
+ stc =
+ listOf(
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = season1Id,
+ parentType = TrackChoiceParentType.SEASON,
+ type = SeriesTrackChoiceType.AUDIO,
+ activation = ActivationFlag.ONLY_FORCED,
+ ),
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = seriesId,
+ parentType = TrackChoiceParentType.SERIES,
+ type = SeriesTrackChoiceType.AUDIO,
+ activation = ActivationFlag.ACTIVATED,
+ language = "spa",
+ ),
+ ),
+ )
+ assertNotNull(result.stream)
+ assertEquals(1, result.stream!!.index)
+ assertTrue(result.reason is StreamChoiceReason.Series)
+ }
+
+ @Test
+ fun `Test STC has forced flag`() =
+ runTest {
+ val stc =
+ listOf(
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = season1Id,
+ parentType = TrackChoiceParentType.SEASON,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.ACTIVATED,
+ trackFlags = listOf(TrackFlag.FORCED).calculateFlag(),
+ ),
+ )
+
+ // Choose forced track
+ streamChoiceService
+ .chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback = null,
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ subtitle(0, "eng", default = true, forced = true),
+ subtitle(1, "spa"),
+ ),
+ stc = stc,
+ ).let { result ->
+ assertNotNull(result.stream)
+ assertEquals(0, result.stream!!.index)
+ assertTrue(result.reason is StreamChoiceReason.Season)
+ }
+
+ // No forced track to choose from, so nothing should be chosen
+ streamChoiceService
+ .chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback = null,
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ subtitle(0, "eng", default = false, forced = false),
+ subtitle(1, "spa"),
+ ),
+ stc = stc,
+ ).let { result ->
+ assertNull(result.stream)
+ }
+ }
+
+ @Test
+ fun `Test prefer SDH`() =
+ runTest {
+ val stc =
+ listOf(
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = season1Id,
+ parentType = TrackChoiceParentType.SEASON,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.ACTIVATED,
+ trackFlags = listOf(TrackFlag.SDH).calculateFlag(),
+ ),
+ )
+
+ // Prefer SDH over default and non-SDH
+ streamChoiceService
+ .chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback = null,
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ subtitle(0, "eng", default = true, forced = true),
+ subtitle(1, "eng", default = false, forced = false, sdh = false),
+ subtitle(2, "eng", default = false, forced = false, sdh = true),
+ ),
+ stc = stc,
+ ).let { result ->
+ assertNotNull(result.stream)
+ assertEquals(2, result.stream!!.index)
+ assertTrue(result.reason is StreamChoiceReason.Season)
+ }
+
+ // No SDH, so prefer regular over forced
+ streamChoiceService
+ .chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback = null,
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ subtitle(0, "eng", default = true, forced = true),
+ subtitle(1, "eng", default = false, forced = false, sdh = false),
+ ),
+ stc = stc,
+ ).let { result ->
+ assertNotNull(result.stream)
+ assertEquals(1, result.stream!!.index)
+ assertTrue(result.reason is StreamChoiceReason.Season)
+ }
+ }
+
+ @Test
+ fun `Test prefer default track with no other flags`() =
+ runTest {
+ val stc =
+ listOf(
+ SeriesTrackChoice(
+ userId = user.rowId,
+ parentId = season1Id,
+ parentType = TrackChoiceParentType.SEASON,
+ type = SeriesTrackChoiceType.SUBTITLE,
+ activation = ActivationFlag.ACTIVATED,
+ trackFlags = 0,
+ ),
+ )
+
+ streamChoiceService
+ .chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback = null,
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ subtitle(0, "eng", default = true, forced = false),
+ subtitle(1, "eng", default = false, forced = false),
+ ),
+ stc = stc,
+ ).let { result ->
+ assertNotNull(result.stream)
+ assertEquals(0, result.stream!!.index)
+ assertTrue(result.reason is StreamChoiceReason.Season)
+ }
+
+ // Test out-of-order tracks
+ streamChoiceService
+ .chooseSubtitleStream(
+ audioStreamLang = "eng",
+ itemPlayback = null,
+ prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
+ candidates =
+ listOf(
+ subtitle(0, "eng", default = false, forced = false),
+ subtitle(1, "eng", default = true, forced = false),
+ ),
+ stc = stc,
+ ).let { result ->
+ assertNotNull(result.stream)
+ assertEquals(1, result.stream!!.index)
+ assertTrue(result.reason is StreamChoiceReason.Season)
+ }
+ }
+}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt
new file mode 100644
index 00000000..d2013f19
--- /dev/null
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt
@@ -0,0 +1,44 @@
+package com.github.damontecres.wholphin.test
+
+import com.github.damontecres.wholphin.data.model.TrackFlag
+import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.EnumSet
+
+class TestTrackFlag {
+ @Test
+ fun basicTest() {
+ val flag = listOf(TrackFlag.DEFAULT, TrackFlag.EXTERNAL).calculateFlag()
+ assertTrue(TrackFlag.DEFAULT.within(flag))
+ assertTrue(TrackFlag.EXTERNAL.within(flag))
+ assertFalse(TrackFlag.FORCED.within(flag))
+ assertFalse(TrackFlag.SDH.within(flag))
+ }
+
+ @Test
+ fun none() {
+ val flag = EnumSet.noneOf(TrackFlag::class.java).calculateFlag()
+ assertFalse(TrackFlag.DEFAULT.within(flag))
+ assertFalse(TrackFlag.EXTERNAL.within(flag))
+ assertFalse(TrackFlag.FORCED.within(flag))
+ assertFalse(TrackFlag.SDH.within(flag))
+ }
+
+ @Test
+ fun all() {
+ val flag = EnumSet.allOf(TrackFlag::class.java).calculateFlag()
+ TrackFlag.entries.forEach { assertTrue(it.toString(), it.within(flag)) }
+ }
+
+ @Test
+ fun checkFlags() {
+ TrackFlag.entries.forEach {
+ assertTrue("$it is not a power of two", it.flag > 0 && (it.flag and (it.flag - 1)) == 0)
+ }
+ val distinctFlags = TrackFlag.entries.map { it.flag }.distinct()
+ assertEquals(TrackFlag.entries.size, distinctFlags.size)
+ }
+}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt
index 34d962da..c291d067 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt
@@ -11,7 +11,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.NameGuidPair
import org.jellyfin.sdk.model.api.UserDto
-import org.junit.Assert.assertNotNull
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@@ -149,3 +148,16 @@ inline fun assertIs(obj: Any?) {
Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ")
}
}
+
+/**
+ * Asserts that the object is not null
+ *
+ * Includes a [contract] to smart-cast the object to T
+ */
+@OptIn(ExperimentalContracts::class)
+fun assertNotNull(obj: Any?) {
+ contract {
+ returns() implies (obj != null)
+ }
+ org.junit.Assert.assertNotNull(obj)
+}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt
index c4a54ec2..b266bb96 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt
@@ -17,15 +17,18 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
+import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
+import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import com.github.damontecres.wholphin.services.SeerrApi
+import com.github.damontecres.wholphin.services.TestMostRecentServerProvider
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.hilt.DatabaseModule
@@ -201,6 +204,10 @@ object TestModule {
fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient,
): SeerrApi = mockk()
+
+ @Provides
+ @Singleton
+ fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider()
}
@Module
@@ -260,6 +267,10 @@ object TestDatabaseModule {
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
+ @Provides
+ @Singleton
+ fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
+
@Provides
@Singleton
fun userPreferencesDataStore(
diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt
index bc879d24..ba7f1e14 100644
--- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt
+++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt
@@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.services.DeletedItem
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.ui.successResponse
+import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler
import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
@@ -34,6 +35,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemFields
+import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.junit.After
@@ -57,6 +59,7 @@ class CollectionFolderViewModelTest {
private val mockUserLibraryApi = mockk()
private val artistsRequest = slot()
+ private val albumArtistsRequest = slot()
private val itemsRequest = slot()
private val libraryId = UUID.randomUUID()
@@ -83,11 +86,14 @@ class CollectionFolderViewModelTest {
// against the relaxed ApiClient
mockkObject(GetItemsRequestHandler)
mockkObject(GetArtistsHandler)
+ mockkObject(GetAlbumArtistsHandler)
mockkObject(GetPersonsHandler)
coEvery { GetItemsRequestHandler.execute(mockApi, capture(itemsRequest)) } returns
successQueryResult(totalRecordCount = LIBRARY_ITEM_COUNT)
coEvery { GetArtistsHandler.execute(mockApi, capture(artistsRequest)) } returns
successQueryResult(totalRecordCount = ARTIST_COUNT)
+ coEvery { GetAlbumArtistsHandler.execute(mockApi, capture(albumArtistsRequest)) } returns
+ successQueryResult(totalRecordCount = ALBUM_ARTIST_COUNT)
coEvery { GetPersonsHandler.execute(mockApi, any()) } returns
successQueryResult(totalRecordCount = PERSON_COUNT)
}
@@ -97,33 +103,36 @@ class CollectionFolderViewModelTest {
WholphinDispatchers.reset()
unmockkObject(GetItemsRequestHandler)
unmockkObject(GetArtistsHandler)
+ unmockkObject(GetAlbumArtistsHandler)
unmockkObject(GetPersonsHandler)
}
- private fun createViewModel(filter: GetItemsFilter) =
- CollectionFolderViewModel(
- savedStateHandle = SavedStateHandle(),
- api = mockApi,
- context = mockk(relaxed = true),
- serverRepository = mockServerRepository,
- libraryDisplayInfoDao = mockk(relaxed = true),
- favoriteWatchManager = mockk(relaxed = true),
- backdropService = mockk(relaxed = true),
- navigationManager = mockk(relaxed = true),
- themeSongPlayer = mockk(relaxed = true),
- userPreferencesService = mockk(relaxed = true),
- mediaManagementService = mockMediaManagementService,
- musicService = mockk(relaxed = true),
- streamChoiceService = mockk(relaxed = true),
- serverReportService = mockk(relaxed = true),
- filterOptionCache = mockk(relaxed = true),
- itemId = libraryId.toString(),
- initialSortAndDirection = null,
- recursive = true,
- collectionFilter = CollectionFolderFilter(filter = filter),
- useSeriesForPrimary = false,
- defaultViewOptions = ViewOptionsSquare,
- )
+ private fun createViewModel(
+ filter: GetItemsFilter,
+ viewOptions: ViewOptions = ViewOptionsSquare,
+ ) = CollectionFolderViewModel(
+ savedStateHandle = SavedStateHandle(),
+ api = mockApi,
+ context = mockk(relaxed = true),
+ serverRepository = mockServerRepository,
+ libraryDisplayInfoDao = mockk(relaxed = true),
+ favoriteWatchManager = mockk(relaxed = true),
+ backdropService = mockk(relaxed = true),
+ navigationManager = mockk(relaxed = true),
+ themeSongPlayer = mockk(relaxed = true),
+ userPreferencesService = mockk(relaxed = true),
+ mediaManagementService = mockMediaManagementService,
+ musicService = mockk(relaxed = true),
+ streamChoiceService = mockk(relaxed = true),
+ serverReportService = mockk(relaxed = true),
+ filterOptionCache = mockk(relaxed = true),
+ itemId = libraryId.toString(),
+ initialSortAndDirection = null,
+ recursive = true,
+ collectionFilter = CollectionFolderFilter(filter = filter),
+ useSeriesForPrimary = false,
+ defaultViewOptions = viewOptions,
+ )
/** Counting via /Items would return every song and album, overshooting the artist grid */
@Test
@@ -143,6 +152,37 @@ class CollectionFolderViewModelTest {
coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any()) }
}
+ @Test
+ fun `positionOfLetter counts album artists for the ALBUM_ARTIST source`() =
+ runTest(testDispatcher) {
+ val viewModel =
+ createViewModel(
+ GetItemsFilter(override = GetItemsFilterOverride.ARTIST),
+ ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)),
+ )
+ advanceUntilIdle()
+
+ val position = viewModel.positionOfLetter('K')
+
+ assertEquals(ALBUM_ARTIST_COUNT, position)
+ assertEquals("K", albumArtistsRequest.captured.nameLessThan)
+ assertEquals(libraryId, albumArtistsRequest.captured.parentId)
+ coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any()) }
+ }
+
+ @Test
+ fun `changing the artist source reloads from the album artists endpoint`() =
+ runTest(testDispatcher) {
+ val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST))
+ advanceUntilIdle()
+ coVerify(exactly = 0) { GetAlbumArtistsHandler.execute(any(), any()) }
+
+ viewModel.saveViewOptions(ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)))
+ advanceUntilIdle()
+
+ coVerify { GetAlbumArtistsHandler.execute(any(), any()) }
+ }
+
/**
* Without SortName, the alphabet highlight falls back to Name and highlights "T" for artists
* like "The Beatles" even though the grid sorts them under "B".
@@ -197,6 +237,7 @@ class CollectionFolderViewModelTest {
companion object {
private const val LIBRARY_ITEM_COUNT = 7315
private const val ARTIST_COUNT = 730
+ private const val ALBUM_ARTIST_COUNT = 215
private const val PERSON_COUNT = 12
}
}
diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt
new file mode 100644
index 00000000..511dbaa2
--- /dev/null
+++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt
@@ -0,0 +1,276 @@
+package com.github.damontecres.wholphin.ui.playback
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.lifecycle.SavedStateHandle
+import com.github.damontecres.wholphin.data.ItemPlaybackDao
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.data.model.Playlist
+import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.PlaylistCreationResult
+import com.github.damontecres.wholphin.services.PlaylistCreator
+import com.github.damontecres.wholphin.services.StreamChoiceReason
+import com.github.damontecres.wholphin.services.StreamChoiceResult
+import com.github.damontecres.wholphin.services.StreamChoiceService
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.test.TestTracks
+import com.github.damontecres.wholphin.test.movie
+import com.github.damontecres.wholphin.test.playlist
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.preferences.ExternalPlayerApp
+import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
+import com.github.damontecres.wholphin.ui.successResponse
+import com.github.damontecres.wholphin.util.LoadingState
+import com.github.damontecres.wholphin.util.WholphinDispatchers
+import com.github.damontecres.wholphin.util.configure
+import com.github.damontecres.wholphin.util.reset
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.playStateApi
+import org.jellyfin.sdk.api.client.extensions.subtitleApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.api.client.extensions.videosApi
+import org.jellyfin.sdk.api.operations.PlayStateApi
+import org.jellyfin.sdk.api.operations.SubtitleApi
+import org.jellyfin.sdk.api.operations.UserLibraryApi
+import org.jellyfin.sdk.api.operations.VideosApi
+import org.jellyfin.sdk.model.api.BaseItemDto
+import org.jellyfin.sdk.model.extensions.inWholeTicks
+import org.junit.After
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.time.Duration.Companion.minutes
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [30])
+class PlayExternalViewModelTest {
+ private val testDispatcher = StandardTestDispatcher()
+ private val context = mockk()
+ private val api = mockk()
+ private val userLibraryApi = mockk()
+ private val videosApi = mockk()
+ private val subtitleApi = mockk